From 439b44eb3b79c07f926649408aaccc739377c186 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 30 Aug 2026 02:09:47 +0530 Subject: [PATCH 1/2] Add organization billing and entitlement infrastructure Define OSS and cloud billing modes with provider-backed catalogs, subscriptions, checkout, plan changes, webhooks, reconciliation, usage reservations, fair-use controls, domain verification, and billing operations tooling. Enforce organization plan limits across REST, MCP, and workers, update the Organizations billing UI and account messaging, and document pricing, errors, provisioning, and acceptable-use policies. --- .env.example | 23 + AGENTS.md | 2 + README.md | 73 +- apps/api/.env.example | 54 + apps/api/docs/organizations.md | 5 + .../pricing-plans-and-payments-integration.md | 2000 ++++ apps/api/drizzle/0005_many_shape.sql | 495 + apps/api/drizzle/meta/0005_snapshot.json | 9013 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/package.json | 3 + apps/api/scripts/billing.ts | 359 + .../automation/process-ongoing-sequence.ts | 38 +- apps/api/src/automation/queries.ts | 22 +- apps/api/src/billing/alerts.test.ts | 76 + apps/api/src/billing/alerts.ts | 288 + apps/api/src/billing/catalog-store.test.ts | 65 + apps/api/src/billing/catalog-store.ts | 364 + apps/api/src/billing/catalog.test.ts | 136 + apps/api/src/billing/catalog.ts | 326 + apps/api/src/billing/checkout.ts | 763 ++ apps/api/src/billing/crypto.test.ts | 44 + apps/api/src/billing/crypto.ts | 78 + apps/api/src/billing/domains.ts | 368 + apps/api/src/billing/entitlements.test.ts | 241 + apps/api/src/billing/entitlements.ts | 922 ++ apps/api/src/billing/errors.ts | 58 + apps/api/src/billing/metrics.ts | 16 + apps/api/src/billing/notifications.ts | 155 + apps/api/src/billing/plan-change.test.ts | 189 + apps/api/src/billing/plan-change.ts | 460 + apps/api/src/billing/policies.test.ts | 82 + apps/api/src/billing/policies.ts | 235 + apps/api/src/billing/portal.ts | 81 + apps/api/src/billing/provider-contract.ts | 160 + apps/api/src/billing/provider-registry.ts | 41 + apps/api/src/billing/provider.ts | 147 + .../src/billing/providers/dodo/index.test.ts | 43 + apps/api/src/billing/providers/dodo/index.ts | 430 + .../src/billing/providers/fake/index.test.ts | 49 + apps/api/src/billing/providers/fake/index.ts | 389 + apps/api/src/billing/reconciliation.ts | 526 + apps/api/src/billing/reputation-config.ts | 60 + apps/api/src/billing/reputation-policy.ts | 63 + apps/api/src/billing/reputation.test.ts | 48 + apps/api/src/billing/reputation.ts | 393 + apps/api/src/billing/routes.csrf.test.ts | 112 + apps/api/src/billing/routes.ts | 506 + apps/api/src/billing/security.test.ts | 101 + apps/api/src/billing/security.ts | 273 + apps/api/src/billing/usage.ts | 75 + apps/api/src/billing/webhook-retry.test.ts | 40 + apps/api/src/billing/webhook-retry.ts | 36 + .../src/billing/webhooks/processor.test.ts | 94 + apps/api/src/billing/webhooks/processor.ts | 697 ++ apps/api/src/billing/webhooks/routes.ts | 130 + apps/api/src/contacts/queries.ts | 104 +- apps/api/src/contacts/routes.ts | 42 +- apps/api/src/db/schema.ts | 701 +- .../src/delivery-feedback/outbound-queries.ts | 8 +- .../src/delivery-feedback/outbound-send.ts | 60 +- .../src/delivery-feedback/process-receipt.ts | 11 + apps/api/src/delivery/queries.ts | 56 + apps/api/src/delivery/quota.ts | 58 +- apps/api/src/index.ts | 26 + apps/api/src/mail/render.ts | 5 +- apps/api/src/mail/worker.ts | 21 + apps/api/src/mcp/policy.ts | 1 + apps/api/src/mcp/server.test.ts | 4 +- apps/api/src/mcp/tools/contacts.ts | 16 +- apps/api/src/mcp/tools/responses.ts | 25 + apps/api/src/mcp/tools/sequences.ts | 3 + apps/api/src/mcp/tools/teams.ts | 71 +- apps/api/src/mcp/tools/transactional.ts | 10 +- apps/api/src/observability/posthog.ts | 4 + apps/api/src/openapi.ts | 5 + .../api/src/organization/default-team-name.ts | 11 + .../organization/enter-team.routes.test.ts | 24 + apps/api/src/organization/queries.test.ts | 232 +- apps/api/src/organization/queries.ts | 574 +- apps/api/src/organization/routes.ts | 340 +- apps/api/src/provisioning/routes.ts | 45 + apps/api/src/sequences/queries.ts | 36 + apps/api/src/sequences/routes.ts | 3 + apps/api/src/team/queries.ts | 10 + apps/api/src/team/routes.ts | 23 +- apps/api/src/test/db.ts | 19 + apps/api/src/test/setup.ts | 3 + apps/api/src/transactional/queries.ts | 12 +- apps/api/src/transactional/routes.ts | 7 + apps/docs/.source/browser.ts | 2 +- apps/docs/content/docs/developers/errors.mdx | 22 + apps/docs/content/docs/developers/mcp.mdx | 3 + .../content/docs/developers/provisioning.mdx | 7 + apps/docs/content/docs/index.mdx | 2 + .../content/docs/workspace/acceptable-use.mdx | 48 + apps/docs/content/docs/workspace/account.mdx | 14 +- apps/docs/content/docs/workspace/meta.json | 10 +- .../content/docs/workspace/organizations.mdx | 8 +- apps/docs/content/docs/workspace/pricing.mdx | 227 + apps/web/app/(dashboard)/account/page.tsx | 39 +- .../(dashboard)/organizations/page.test.tsx | 361 +- .../app/(dashboard)/organizations/page.tsx | 2482 ++++- apps/web/app/api/proxy/[...path]/route.ts | 45 +- apps/web/components/dashboard/app-sidebar.tsx | 2 - apps/web/components/dashboard/banner.tsx | 8 +- .../components/dashboard/team-switcher.tsx | 6 +- apps/web/lib/api.ts | 273 +- apps/web/lib/navigation.ts | 3 + eslint.config.cjs | 6 + packages/api-contract/src/contract.ts | 220 +- packages/api-contract/src/index.ts | 1 + packages/api-contract/src/schemas/billing.ts | 158 + .../api-contract/src/schemas/organizations.ts | 8 +- packages/email-blocks/src/footer/block.tsx | 3 + packages/email-blocks/src/footer/types.ts | 2 + pnpm-lock.yaml | 23 +- 116 files changed, 27485 insertions(+), 522 deletions(-) create mode 100644 apps/api/docs/pricing-plans-and-payments-integration.md create mode 100644 apps/api/drizzle/0005_many_shape.sql create mode 100644 apps/api/drizzle/meta/0005_snapshot.json create mode 100644 apps/api/scripts/billing.ts create mode 100644 apps/api/src/billing/alerts.test.ts create mode 100644 apps/api/src/billing/alerts.ts create mode 100644 apps/api/src/billing/catalog-store.test.ts create mode 100644 apps/api/src/billing/catalog-store.ts create mode 100644 apps/api/src/billing/catalog.test.ts create mode 100644 apps/api/src/billing/catalog.ts create mode 100644 apps/api/src/billing/checkout.ts create mode 100644 apps/api/src/billing/crypto.test.ts create mode 100644 apps/api/src/billing/crypto.ts create mode 100644 apps/api/src/billing/domains.ts create mode 100644 apps/api/src/billing/entitlements.test.ts create mode 100644 apps/api/src/billing/entitlements.ts create mode 100644 apps/api/src/billing/errors.ts create mode 100644 apps/api/src/billing/metrics.ts create mode 100644 apps/api/src/billing/notifications.ts create mode 100644 apps/api/src/billing/plan-change.test.ts create mode 100644 apps/api/src/billing/plan-change.ts create mode 100644 apps/api/src/billing/policies.test.ts create mode 100644 apps/api/src/billing/policies.ts create mode 100644 apps/api/src/billing/portal.ts create mode 100644 apps/api/src/billing/provider-contract.ts create mode 100644 apps/api/src/billing/provider-registry.ts create mode 100644 apps/api/src/billing/provider.ts create mode 100644 apps/api/src/billing/providers/dodo/index.test.ts create mode 100644 apps/api/src/billing/providers/dodo/index.ts create mode 100644 apps/api/src/billing/providers/fake/index.test.ts create mode 100644 apps/api/src/billing/providers/fake/index.ts create mode 100644 apps/api/src/billing/reconciliation.ts create mode 100644 apps/api/src/billing/reputation-config.ts create mode 100644 apps/api/src/billing/reputation-policy.ts create mode 100644 apps/api/src/billing/reputation.test.ts create mode 100644 apps/api/src/billing/reputation.ts create mode 100644 apps/api/src/billing/routes.csrf.test.ts create mode 100644 apps/api/src/billing/routes.ts create mode 100644 apps/api/src/billing/security.test.ts create mode 100644 apps/api/src/billing/security.ts create mode 100644 apps/api/src/billing/usage.ts create mode 100644 apps/api/src/billing/webhook-retry.test.ts create mode 100644 apps/api/src/billing/webhook-retry.ts create mode 100644 apps/api/src/billing/webhooks/processor.test.ts create mode 100644 apps/api/src/billing/webhooks/processor.ts create mode 100644 apps/api/src/billing/webhooks/routes.ts create mode 100644 apps/api/src/organization/default-team-name.ts create mode 100644 apps/docs/content/docs/workspace/acceptable-use.mdx create mode 100644 apps/docs/content/docs/workspace/pricing.mdx create mode 100644 apps/web/lib/navigation.ts create mode 100644 packages/api-contract/src/schemas/billing.ts diff --git a/.env.example b/.env.example index 5f8ed50..75b43b3 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,29 @@ ESP_CREDENTIALS_ENCRYPTION_KEY= # service. Its API key is shown exactly once in `docker compose logs init`. SUPER_ADMIN_EMAIL=admin@example.com +# Explicit billing mode. Use oss for self-hosted installs. Cloud requires the +# provider/catalog variables documented in apps/api/.env.example. +SENDLIT_DEPLOYMENT_MODE=oss +# Amounts are integer minor units (cents for USD). Raising a price requires a +# new provider product ID and a higher BILLING_CATALOG_REVISION. Paid amounts +# are never source constants. See apps/api/.env.example. + +# Optional cloud fair-use controls (defaults match the published policy). +# BILLING_FAIR_USE_MIN_ACCEPTED=500 +# BILLING_FAIR_USE_BOUNCE_WARN_BPS=200 +# BILLING_FAIR_USE_COMPLAINT_WARN_BPS=5 +# BILLING_FAIR_USE_BOUNCE_PAUSE_BPS=500 +# BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS=10 +# BILLING_FAIR_USE_COMPLAINT_STOP_BPS=30 +# BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE=10 +# BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT=100 +# BILLING_FAIR_USE_MINIMUM_HOLD_HOURS=72 +# BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS=7 +# BILLING_RAMP_DAYS_0_2_LIMIT=200 +# BILLING_RAMP_DAYS_3_6_LIMIT=1000 +# BILLING_RAMP_DAYS_7_13_LIMIT=10000 +# BILLING_TEST_VOLUME_THRESHOLD=100 + # Public origins. For a local installation, keep these defaults. For a public # deployment, use the externally reachable HTTPS origins and set PROTOCOL=https. API_PUBLIC_URL=http://localhost:5000 diff --git a/AGENTS.md b/AGENTS.md index dcfa57e..9dda198 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,8 @@ - Don't duplicate stuff over and over. Re-use existing code and libraries. - While making changes to the `apps/api` directory, make sure the REST API documentation and MCP server are updated as well. - For UI components, use shadcn/ui exclusively. Always use Shadcn CLI for installing components. Never hand roll standard Shadcn components. Prefer shadcn/ui components over browser-native components. +- When dealing with a large change, work through it in layers: money-path correctness first, then enforcement, then dashboard/self-serve, then ops and docs. +- If you are a Grok model, make sure you run the linter and tests before declaring any task done. ## Architecture Tips diff --git a/README.md b/README.md index a6c8e73..f49f09f 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,73 @@ analytics, bounce handling and multi-user accounts are still on the roadmap. - `packages/email-blocks` — headless composing blocks for broadcasts/ sequences/templates (`@sendlit/email-blocks`), used by `apps/web`. -## Running everything locally - -1. Start Postgres and Redis (e.g. via Docker). -2. `apps/api`: copy `.env.example` to `.env`, fill in the values, then - `pnpm --filter @sendlit/api db:push` and `pnpm --filter @sendlit/api dev`. -3. `apps/web`: copy `.env.example` to `.env.local` (`API_URL` pointing at the - API above), then `pnpm --filter @sendlit/web dev`. -4. Build the two shared packages at least once so `apps/web` has something to +## Local development + +Start Postgres and Redis, then run the API and web app on the host. + +```sh +docker run -d --name sendlit-postgres --restart unless-stopped \ + -e POSTGRES_DB=sendlit \ + -e POSTGRES_USER=sendlit \ + -e POSTGRES_PASSWORD=sendlit \ + -p 5432:5432 \ + postgres:17-alpine + +docker run -d --name sendlit-redis --restart unless-stopped \ + -p 6379:6379 \ + redis:7-alpine redis-server --appendonly yes +``` + +These ports match `apps/api/.env.example` (`localhost:5432` and `localhost:6379`). +If a host port is already in use, change the left-hand side of `-p` and update +`DB_CONNECTION_STRING` or `REDIS_PORT` to match. + +To wipe the local Postgres data and start over, remove the container (and its +volume) and run the `docker run` command again: + +```sh +docker rm -fv sendlit-postgres +``` + +Then: + +1. `apps/api`: copy `.env.example` to `.env` and fill in the values, then + `pnpm --filter @sendlit/api db:push`. +2. `apps/web`: copy `.env.example` to `.env.local` (`API_URL` pointing at the + API above). +3. Build the two shared packages at least once so `apps/web` has something to import: `pnpm --filter @sendlit/email-editor build && pnpm --filter @sendlit/email-blocks build` (re-run, or use their `dev` scripts, after changing either package). +4. From the repo root, start the apps you need: + + ```sh + pnpm dev:api + pnpm dev:web + pnpm dev:docs + ``` + +## Operator billing CLI + +Cloud billing recovery is a CLI, not the dashboard. From the repo root it +loads `apps/api/.env` and talks to that API's database: + +```sh +pnpm --filter @sendlit/api billing catalog-status +pnpm --filter @sendlit/api billing catalog-verify +``` + +Run it with no arguments for the full list. Subcommands: + +- `catalog-status` / `catalog-verify` / `catalog-abandon --reason ` +- `reconcile-org ` +- `webhook-retry ` / `webhook-inspect ` +- `set-override --teams |none --contacts |none --reason ` +- `reputation-apply --operator --reason ` +- `reputation-release --operator --reason ` +- `cancel-subscription --reason ` + +OSS mode has nothing to verify. A new `BILLING_CATALOG_REVISION` is recorded on +API startup; `catalog-verify` checks it against Dodo and activates it. ## Self-hosting with Docker Compose diff --git a/apps/api/.env.example b/apps/api/.env.example index 84d10f8..8c980be 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,6 +8,60 @@ REDIS_PORT=6379 PORT=5000 NODE_ENV=development +# Billing deployment mode is explicit. Use oss for local/self-hosted installs. +SENDLIT_DEPLOYMENT_MODE=oss +# Cloud-only catalog/provider settings (required when mode=cloud): +# BILLING_CHECKOUT_PROVIDER=dodo +# BILLING_ENABLED_PROVIDERS=dodo,stripe +# Tests may use BILLING_CHECKOUT_PROVIDER=fake. Production cloud refuses fake. +# BILLING_CATALOG_REVISION= +# BILLING_CURRENCY= +# BILLING_PRO_MONTH_AMOUNT_MINOR= +# BILLING_PRO_YEAR_AMOUNT_MINOR= +# BILLING_BUSINESS_MONTH_AMOUNT_MINOR= +# BILLING_BUSINESS_YEAR_AMOUNT_MINOR= +# DODO_PAYMENTS_API_KEY= +# DODO_PAYMENTS_WEBHOOK_KEY_CURRENT= +# DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS= +# DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT= +# DODO_PAYMENTS_ENVIRONMENT=test_mode +# Maximum age of the sign-in that may mint a single-use billing action token. +# BILLING_RECENT_AUTH_MAX_AGE_SECONDS=900 +# DODO_PRO_MONTH_PRODUCT_ID= +# DODO_PRO_YEAR_PRODUCT_ID= +# DODO_BUSINESS_MONTH_PRODUCT_ID= +# DODO_BUSINESS_YEAR_PRODUCT_ID= +# Billing checkout URLs and webhook payloads are encrypted at rest. +# BILLING_DATA_ENCRYPTION_KEY= # base64 encoding of exactly 32 random bytes +# BILLING_DATA_ENCRYPTION_KEY_VERSION=v1 +# BILLING_DATA_ENCRYPTION_KEY_PREVIOUS= # optional during key rotation +# Admin paging for billing SLOs (comma-separated). Falls back to SUPER_ADMIN_EMAIL. +# BILLING_ALERT_EMAIL= +# Operator recovery (catalog verify/abandon, webhook retry, overrides, +# reputation release, emergency cancel) is `pnpm --filter @sendlit/api billing`. +# Required in cloud mode. Dedicated HMAC key for one-time trial eligibility. +# Do not reuse BETTER_AUTH_SECRET. Generate with: openssl rand -base64 32 +# BILLING_TRIAL_EMAIL_HMAC_KEY= +# BILLING_TRIAL_EMAIL_HMAC_KEY_VERSION=v1 +# BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS= +# BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS_VERSION= +# Fair-use controls (defaults match the published policy; override per deploy). +# BILLING_FAIR_USE_MIN_ACCEPTED=500 +# BILLING_FAIR_USE_BOUNCE_WARN_BPS=200 +# BILLING_FAIR_USE_COMPLAINT_WARN_BPS=5 +# BILLING_FAIR_USE_BOUNCE_PAUSE_BPS=500 +# BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS=10 +# BILLING_FAIR_USE_COMPLAINT_STOP_BPS=30 +# BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE=10 +# BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT=100 +# BILLING_FAIR_USE_MINIMUM_HOLD_HOURS=72 +# BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS=7 +# Paid marketing ramp (messages per organization per UTC day). +# BILLING_RAMP_DAYS_0_2_LIMIT=200 +# BILLING_RAMP_DAYS_3_6_LIMIT=1000 +# BILLING_RAMP_DAYS_7_13_LIMIT=10000 +# BILLING_TEST_VOLUME_THRESHOLD=100 + # Public URL Better Auth uses for OAuth callbacks, issuers and trusted origins. API_PUBLIC_URL=http://localhost:5000 diff --git a/apps/api/docs/organizations.md b/apps/api/docs/organizations.md index ea38c29..92f37aa 100644 --- a/apps/api/docs/organizations.md +++ b/apps/api/docs/organizations.md @@ -1526,6 +1526,11 @@ DELETE /organizations/:organizationId ``` - Signup automatically creates the first organization and owner membership. +- Any automatically-created initial/default team is named from its + organization (` Team`) unless a flow supplies an explicit + team name. +- An owner cannot create another active, suspended, or pending organization + whose name differs only by casing; closed and abandoned names may be reused. - Additional organization creation requires an authenticated user. - Responses contain public organization data only. - `DELETE` is owner-only, audited, and changes status to `closed`. diff --git a/apps/api/docs/pricing-plans-and-payments-integration.md b/apps/api/docs/pricing-plans-and-payments-integration.md new file mode 100644 index 0000000..f9fa400 --- /dev/null +++ b/apps/api/docs/pricing-plans-and-payments-integration.md @@ -0,0 +1,2000 @@ +# PRD: Pricing plans, entitlements, and payments integration + +_Status: implementation-ready product and architecture specification. Date: +2026-08-28. Owners: SendLit API, Web, and Operations. Initial payment provider: +Dodo Payments. Source of truth: the SendLit pricing specification in +`product-marketing/sendlit/pricing.md`._ + +## Executive summary + +SendLit will offer four plans: + +- **OSS**: the full self-hosted product, with no SendLit plan limits or + checkout; +- **Free**: managed cloud, one team, 1,000 subscribed contacts, and 3,000 + sends per calendar month; +- **Pro**: managed cloud at the configured monthly or yearly price, with up to + five teams, 10,000 subscribed contacts, and shared organization mailboxes; + and +- **Business**: managed cloud at the configured monthly or yearly price, with + up to 25 teams, no published contact cap, shared organization mailboxes, and + the provisioning API. + +Billing belongs to an **organization**. A login is never billed, team and +organization members are never seats, and each paid organization has its own +subscription. A user can be invited to any number of organizations without +being charged. + +Dodo Payments is the first card, tax, invoice, checkout, and portal provider. +Provider-specific SDK types, product IDs, webhook payloads, and status names +must remain inside a billing-provider adapter. SendLit owns the canonical plan +catalog, entitlement decisions, usage counters, grace-period behavior, and +organization billing projection. Replacing Dodo with Stripe, Lemon Squeezy, +Paddle, Polar, or another provider must not require changing domain policy or +product-facing API contracts. + +Plan enforcement will be centralized, but not implemented only as Express +middleware. REST middleware can resolve and attach an organization plan, but +MCP tools and background workers do not traverse Express, and capacity limits +must be checked in the same database transaction as the protected write. The +durable design is: + +```text + ┌───────────────────────────┐ +REST ─ auth/context ─┤ │ +MCP ─ auth/context ──┤ Entitlement/policy engine ├─ domain guards ─ DB writes +Workers ─ team/org ──┤ │ or transport + └───────────────────────────┘ + ▲ + │ + canonical plan + payment state +``` + +REST uses thin middleware over this engine for coarse feature gates and a +consistent error response. Every protected domain mutation and final send +boundary uses the same engine directly. This keeps pricing policy in one place +without creating REST-only bypasses or race-prone count checks. + +## Goals + +1. Make every cloud organization Free, Pro, or Business and every self-hosted + installation effectively OSS. +2. Implement organization-scoped checkout, subscription lifecycle, billing + portal access, trials, payment recovery, and cancellation without billing + accounts or teams. +3. Enforce all published plan capabilities and capacity limits consistently + across REST, MCP, dashboard actions, provisioning, automations, and workers. +4. Keep plan definitions and policy decisions independent from Dodo product + objects and webhook vocabulary. +5. Preserve data on downgrade and provide stable, actionable limit errors with + organization-specific upgrade links. +6. Reuse one provider customer for later checkouts by the same authenticated + payer while keeping one subscription per paid organization. +7. Make webhook processing signed, idempotent, replay-safe, and repairable by + reconciliation. +8. Give organization owners a clear plan, usage, upgrade, and billing workflow + in the existing Organizations area. +9. Preserve complete OSS functionality only under explicit OSS deployment + mode, without requiring a cloud billing provider. + +## Non-goals + +- Charging per email on Pro or Business +- Selling extra team or contact packs +- Charging per user, member, or seat +- Building a SendLit invoice, tax, card, or payment-method UI +- Adding Platform, Enterprise, or another public plan +- Including the later SendLit Send delivery product +- Licensing or remotely restricting OSS installations +- Making provider product data the entitlement source of truth +- Exposing checkout, portal, invoices, or payment methods through MCP +- Supporting multiple simultaneous active subscriptions for one organization + +## Locked product decisions + +### Billing boundary + +- The organization is the plan, subscription, usage, and entitlement boundary. +- Each paid organization has one subscription. Two Pro organizations cost + twice the configured Pro price for the selected interval. +- A team inherits its parent organization's effective plan. Teams are never + individually billed. +- The authenticated user who starts checkout is the billing manager/payer for + that subscription. This is separate from organization membership. +- Organization members and team members remain unlimited and free on every + plan. +- Invited organization membership does not count toward the one-Free-org rule. + +### Catalog + +| Policy | OSS | Free | Pro | Business | +| ------------------------------------------------- | ----------------: | ----------: | ---------: | ---------------: | +| Deployment | Self-hosted | Cloud | Cloud | Cloud | +| Monthly price | $0 | $0 | Configured | Configured | +| Yearly price | $0 | $0 | Configured | Configured | +| Teams | Unlimited | 1 | 5 | 25 by default | +| Subscribed contacts per organization | Unlimited | 1,000 | 10,000 | No published cap | +| SendLit send charge | None | None | None | None | +| Cloud send allowance | Unlimited locally | 3,000/month | Fair use | Fair use | +| Team ESP | Yes | Yes | Yes | Yes | +| Shared organization mailbox and grants | Yes | No | Yes | Yes | +| Provisioning and organization API keys | Yes | No | No | Yes | +| Sequences, transactional, REST, MCP, React blocks | Yes | Yes | Yes | Yes | +| “Sent with SendLit” on marketing mail | Off | On | Off | Off | + +Business `teamsLimitOverride` and `contactsLimitOverride` support negotiated +capacity without creating a fifth plan or an add-on catalog. A null contact +limit means no published product cap, not permission to exhaust storage. + +### Prices, intervals, and trials + +| Catalog key | Amount source | Trial | +| ---------------- | ------------------------------------- | ------------------------ | +| `pro_month` | `BILLING_PRO_MONTH_AMOUNT_MINOR` | 14 days | +| `pro_year` | `BILLING_PRO_YEAR_AMOUNT_MINOR` | None; charge immediately | +| `business_month` | `BILLING_BUSINESS_MONTH_AMOUNT_MINOR` | None | +| `business_year` | `BILLING_BUSINESS_YEAR_AMOUNT_MINOR` | None; charge immediately | + +- Paid amounts and currency are deployment configuration, not code constants. + Amounts use positive integer minor units; never parse money from floating + point or a preformatted string. +- The client reads the active offers from SendLit's billing-catalog endpoint. + It never contains plan amounts or provider product IDs in the bundle and + never supplies an authoritative amount to checkout. +- The server resolves canonical `plan` and `interval` plus catalog revision to + the configured amount, currency, and provider product ID. +- Changing a price requires updating the payment-provider product, its product + ID/amount environment values, and the monotonically increasing catalog + revision. It does not require a source-code change. +- The Pro monthly trial can be redeemed once per verified SendLit account and + normalized verified email. Eligibility is reserved atomically when checkout + starts and becomes permanently redeemed when the provider creates a + `trialing` or `active` subscription. Changing the account email does not + restore eligibility. +- SendLit owns the upgrade, downgrade, and billing-interval-change experience. + The provider remains responsible for charging the stored payment method, + tax, invoices, and payment failures. SendLit calls the provider's + subscription API through its adapter and never requires a provider portal + for a plan change. Cancellation, invoice payment, and card updates remain in + the provider portal. +- An upgrade (including a move from monthly to yearly) takes effect + immediately with the provider's supported proration policy. A downgrade + (including yearly to monthly) takes effect at the next billing date by + default. SendLit persists the requested transition and does not change + entitlements until a verified provider snapshot confirms it. +- SendLit changes effective paid entitlements only from a verified webhook or + provider reconciliation result, never from a checkout return URL. + +### One owned Free organization + +- Creating an active Free organization or becoming an owner of one is allowed + only when the user does not already own an active Free organization. +- Admin/member invitations do not count. Promoting an invited user to `owner` + does count and uses the same policy guard. +- A second organization can be created through a paid checkout flow. Until + payment or trial activation is confirmed, it is `pending_payment`, cannot + send, and does not count as the user's Free organization. +- Provider-driven cancellation or expiry must never fail and must never delete + data. It may leave a user owning more than one downgraded Free organization. + This is the deliberate non-retroactive exception to the creation rule: the + user cannot create or acquire another Free organization, but existing paid + data is not frozen merely to enforce the signup anti-farming rule. +- Pending-payment organizations must not create a default team until + activation. After every checkout attempt has expired, they may be marked + `abandoned` and hidden from the normal organization picker, but the row and + checkout correlation records are retained. A late provider event must never + activate a tombstoned or reassigned organization; it is quarantined for + operator review and the provider subscription is cancelled/refunded through + an explicit support workflow when appropriate. + +### Downgrades + +- Downgrades never delete organizations, teams, contacts, ESP configurations, + grants, templates, sequences, or logs. +- Teams in `active` or `sending_suspended` status count toward the team cap; + archived teams do not. An over-limit organization cannot create another + team. +- Every subscribed contact in the organization counts, including contacts in + archived teams. The same email in two teams counts twice. Transactional-only + recipients are not contacts and do not count. +- When subscribed contacts exceed the effective cap, existing contacts remain + available for export, unsubscribe, and deletion. New subscribed contacts, + re-subscription, subscribed-contact imports, new sequence enrollment, and + marketing sends are blocked until usage is below the cap or the organization + upgrades. Transactional sends to transactional-only recipients continue. +- On downgrade to Free, existing shared mailboxes and grants remain stored and + readable but cannot be created, changed, activated, granted, or used for a + new send. Team ESP delivery remains available. +- On Business to Pro, all mutating provisioning operations stop immediately. + Existing organization keys remain stored so they can be audited/revoked, but + authentication through them does not bypass the plan. +- A cancellation scheduled for period end retains paid entitlements through + `currentPeriodEndsAt`. At expiry, the organization becomes Free. + +### Payment failure + +- `past_due` starts a seven-day grace period and notifies the billing manager + and organization owners. +- Paid plan features remain available during grace. +- If payment has not recovered when `graceEndsAt` passes, all new marketing + and transactional sends stop at both acceptance and worker boundaries. + Reads, exports, cleanup, billing management, and plan recovery remain + available. +- Recovery before or after the deadline restores sending without data + migration. + +## Current platform baseline + +The implementation must extend, not replace, these existing boundaries: + +- `organizations` is already the durable customer boundary above teams. +- `organization_members` and `team_members` are independent authorization + relationships. +- `POST /organizations/:organizationId/teams`, `POST /teams`, MCP + `create_team`, and `POST /provisioning/teams` are separate team-creation + paths that currently call the same team query layer. +- Organization ESPs, grants, delivery policies, scoped organization keys, and + the provisioning lifecycle already exist. +- Team resource routers use `requireAuth` and `requireTeam`. +- REST request/response schemas live in `packages/api-contract`; those + contracts validate the API, generate OpenAPI, and power the web client. +- MCP tools call domain/query functions directly and therefore do not pass + through REST middleware. +- `outbound_messages` is already a common per-recipient ledger for campaigns, + sequences, and transactional sends. +- Organization-ESP quota reservations already demonstrate the required atomic + reserve/commit/release pattern. +- Bounce and complaint receipts, normalized events, suppressions, and pinned + delivery sources already exist, but automated reputation-plan enforcement + does not. +- The Account page currently contains a Free-plan billing placeholder. This is + incorrect because accounts are not billed. +- The Organizations page is the existing organization/team management + surface. Plan and upgrade controls belong there, including the action shown + on each team item; the action always upgrades that team's parent + organization. + +## Canonical plan and entitlement model + +### Plan catalog + +Create one provider-neutral catalog in `apps/api/src/billing/plans.ts` (or an +equivalent module): + +```ts +type PlanId = "oss" | "free" | "pro" | "business"; +type BillingInterval = "month" | "year"; +type PaymentStatus = + | "free" + | "checkout_pending" + | "trialing" + | "active" + | "past_due" + | "cancel_at_period_end" + | "cancelled" + | "expired"; + +type PlanPolicy = { + teamsLimit: number | null; + subscribedContactsLimit: number | null; + monthlySendsLimit: number | null; + sharedOrganizationMailbox: boolean; + provisioning: boolean; + organizationApiKeys: boolean; + marketingBranding: boolean; + fairUse: boolean; +}; + +type BillingOffer = { + catalogKey: "pro_month" | "pro_year" | "business_month" | "business_year"; + catalogRevision: number; + plan: "pro" | "business"; + interval: BillingInterval; + currency: string; + amountMinor: number; + provider: BillingProviderId; + providerProductId: string; + trialDays: number; +}; +``` + +Plan policy, default limits, grace duration, ramp limits, and abuse thresholds +must be exported from this domain catalog or its adjacent policy configuration. +They must not be duplicated in routes, React components, MCP tools, workers, +or a Dodo adapter. + +`BillingOffer` is loaded from validated deployment configuration through a +provider-neutral catalog loader. It is not declared as a literal array with +amounts in TypeScript. Provider IDs remain private even though the public API +returns the other display fields. + +`PaymentStatus` is a public presentation value derived from checkout attempts, +the current subscription, `cancelAtPeriodEnd`, and time boundaries. It is not +a provider status or a database state machine; in particular, +`cancel_at_period_end` is derived while the underlying subscription remains +`active` or `trialing`. + +Current provider catalog mappings and amounts are separate, versioned +configuration: + +```text +pro_month -> BILLING_PRO_MONTH_AMOUNT_MINOR + DODO_PRO_MONTH_PRODUCT_ID +pro_year -> BILLING_PRO_YEAR_AMOUNT_MINOR + DODO_PRO_YEAR_PRODUCT_ID +business_month -> BILLING_BUSINESS_MONTH_AMOUNT_MINOR + DODO_BUSINESS_MONTH_PRODUCT_ID +business_year -> BILLING_BUSINESS_YEAR_AMOUNT_MINOR + DODO_BUSINESS_YEAR_PRODUCT_ID +``` + +Startup validation must reject cloud mode when any required product ID, API +key, price amount, currency, or webhook key is missing/invalid, when the +catalog revision is not a positive integer, or when two catalog keys map to +the same provider product unexpectedly. Keep reverse mappings for every product ID +referenced by a nonterminal or retained subscription, including products from +a provider no longer used for new checkout. A catalog mapping must not be +removed until no subscription or webhook reconciliation can reference it. + +### Effective plan resolution + +`getOrganizationEntitlements(organizationId)` returns one canonical snapshot: + +```ts +type OrganizationEntitlements = { + organizationId: string; + plan: PlanId; + interval: BillingInterval | null; + paymentStatus: PaymentStatus; + teamsLimit: number | null; + subscribedContactsLimit: number | null; + monthlySendsLimit: number | null; + sharedOrganizationMailbox: boolean; + provisioning: boolean; + organizationApiKeys: boolean; + marketingBranding: boolean; + canSend: boolean; + graceEndsAt: Date | null; +}; +``` + +Resolution rules: + +1. `SENDLIT_DEPLOYMENT_MODE=oss` is the only way to enter OSS mode. Every + organization resolves to OSS and provider checkout, portal, webhooks, and + all plan limits are disabled. +2. `SENDLIT_DEPLOYMENT_MODE=cloud` is the only way to enter cloud mode. An + organization without a current entitlement-bearing subscription resolves + to Free. +3. `trialing`, `active`, and `past_due` before the grace deadline retain the + selected paid plan. `cancelAtPeriodEnd` is an attribute, not a status. +4. `past_due` after grace retains the paid feature shape for display but sets + `canSend` false. +5. A `cancelled` subscription retains paid entitlements only when + `cancelAtPeriodEnd` is true and the verified `paidThroughAt` is still in + the future. Immediate cancellation (`cancelAtPeriodEnd=false`) drops paid + access immediately even if a future `paidThroughAt` remains. `expired` + grants none. The plan projection then resolves to Free. +6. Limit overrides replace only their named limit and never enable another + plan's capabilities. + +Deployment mode is deliberately not inferred from `NODE_ENV`, a missing API +key, or the presence of a provider variable. Startup fails closed when the +mode is absent, when OSS mode has a checkout provider configured, or when +cloud mode lacks a valid checkout provider, enabled-provider set, credentials, +webhook keys, or complete product catalog. Cloud always enforces plan gates; +it must never silently fall back to OSS or Free semantics. + +The database projection is the low-latency entitlement source. Do not call the +payment provider during an ordinary API request or worker job. Webhooks and a +reconciliation process update the projection. + +V1 uses request/job-local memoization only, not a process-local entitlement +cache. If a distributed cache is added later, entries must be keyed/versioned +by `projection_version`, invalidated after transaction commit, bounded to 30 +seconds, and bypassed at the final send boundary. Deadline, bucket, lease, and +paid-through comparisons use PostgreSQL UTC time so API/worker clock skew +cannot grant extra entitlement. + +## Persistence + +Names may follow repository conventions, but the model must preserve these +separations. + +### Billing catalog persistence + +Persist verified environment-backed prices separately from catalog revisions +so a revision can change one price while reusing the other provider products. + +`billing_price_entries`: + +```text +id +catalog_key pro_month | pro_year | business_month | + business_year +plan / billing_interval +currency uppercase ISO 4217 code +amount_minor positive safe integer +provider / provider_product_id +verified_at +created_at / updated_at +``` + +Unique `(provider, provider_product_id)`. Provider product IDs are treated as +immutable price identities: reusing one with a different catalog key, +interval, currency, or amount is a catalog mismatch. + +`billing_catalog_revisions` and `billing_catalog_revision_items`: + +```text +billing_catalog_revisions: + id / revision unique positive integer + checkout_provider + status pending_verification | active | retired | + invalid | abandoned + verified_at / activated_at / retired_at + created_at / updated_at + +billing_catalog_revision_items: + catalog_revision_id + catalog_key + billing_price_entry_id +``` + +Unique `(catalog_revision_id, catalog_key)`; every revision has exactly the +four required keys and exactly one revision is active. All revision items and +the active/retired switch commit atomically. + +On cloud startup, the catalog loader synchronously validates configuration +shape and idempotently records a higher requested revision as pending; +ordinary API startup does not wait on provider availability. A +catalog-verification job retrieves the four provider products, upserts their +immutable price entries, and verifies product identity, recurring interval, +currency, and amount. A pending/invalid higher requested revision alerts and +disables new checkout deployment-wide until corrected; it never changes an +entitlement or charges a guessed amount. Only a fully verified revision higher +than the current active revision may atomically become active. Older +application instances read the active database revision and cannot roll it +back during a rolling deployment. + +A price change should use a new provider product/price ID and a higher catalog +revision; unchanged keys reuse their existing price entries. Previous +revisions retire for new checkout, but their price entries remain available +for webhook/reconciliation lookup as long as any attempt or subscription +references them. Existing subscriptions retain their stored price entry and +are not silently repriced; migrating them is a separate explicit provider +operation and notification workflow. An audited operator command can abandon +a pending/invalid revision after its environment rollout is reverted, restoring +checkout on the prior active revision without deleting catalog history. + +Reverify active entries at least hourly. Checkout also retrieves and compares +the selected provider product immediately before customer/session creation. A +changed/mismatched amount, currency, interval, or identity atomically marks the +catalog unavailable, alerts, and creates no provider checkout session. This +fresh check is allowed to depend on provider availability because checkout +already does; ordinary product traffic and existing entitlements do not. + +### `organization_plan_states` + +One provider-neutral entitlement projection per organization, created in the +same transaction as the organization: + +```text +organization_id unique FK -> organizations.id +plan free | pro | business +active_subscription_id nullable FK -> organization_subscriptions.id +teams_limit_override nullable positive integer +contacts_limit_override nullable positive integer +projection_version non-negative integer +first_paid_activated_at nullable timestamp +ramp_stage 0 | 1 | 2 | 3 (unlimited) +ramp_clean_stage_days non-negative integer +ramp_evaluated_at nullable timestamp +created_at / updated_at +``` + +Constraints: + +- Free rows have no active subscription. +- Overrides are positive integers or null. +- Provider IDs are opaque text and are never returned from public REST or MCP + responses. +- A plan-state change writes an organization audit event with old/new + canonical values but no raw provider payload. +- Only `active_subscription_id` can grant paid entitlements. A late event for + a historical subscription may update that subscription but cannot replace + this pointer unless the subscription-activation state machine explicitly + wins the organization lock. + +OSS is an effective deployment plan rather than a paid row value. This avoids +rewriting every organization. Public API responses report `plan: "oss"` only +when the explicit deployment mode is OSS. + +### `organization_subscriptions` + +Keep an immutable-identity row for every provider subscription, including +historical and migrated subscriptions: + +```text +id +organization_id FK -> organizations.id +billing_customer_id FK -> billing_provider_customers.id +billing_manager_user_id FK -> user.id +provider dodo | stripe | ... +provider_subscription_id +provider_product_id +billing_price_entry_id FK -> billing_price_entries.id +catalog_key pro_month | pro_year | business_month | + business_year +plan pro | business +billing_interval month | year +status pending | trialing | active | past_due | + cancelled | expired +current_period_starts_at nullable +current_period_ends_at nullable +paid_through_at nullable +trial_ends_at nullable +past_due_at nullable +grace_ends_at nullable +cancel_at_period_end boolean +is_entitlement_source boolean +last_provider_event_at nullable +last_reconciled_at nullable +created_at / updated_at +``` + +Constraints and projection rules: + +- Unique `(provider, provider_subscription_id)`. +- At most one row per organization has `is_entitlement_source = true`. + Enforce this with a partial unique database constraint and always with an + organization-scoped transaction lock. This includes a cancelled + subscription with future `paid_through_at`, not only `trialing`, `active`, + and `past_due` statuses. +- `past_due_at` is the first timestamp in the current uninterrupted past-due + episode. Duplicate `on_hold` events never extend `grace_ends_at`. +- `cancel_at_period_end` is a boolean and never a status. `paid_through_at` is + derived only from a verified provider snapshot. +- Provider changes create another subscription row; they never overwrite the + identity or history of an old one. +- Applying a snapshot locks the plan-state and relevant subscription rows. A + valid replacement clears the old source flag before setting the new one, + updates the active pointer, increments `projection_version`, and writes the + audit event in one transaction. + +Canonical entitlement behavior is: + +| Subscription state | Paid entitlement | +| ------------------ | ---------------------------------------------------------------- | +| `pending` | None | +| `trialing` | Yes, through verified trial/period end | +| `active` | Yes | +| `past_due` | Yes until the fixed seven-day grace deadline; sending then stops | +| `cancelled` | Only until verified `paid_through_at`, if it is in the future | +| `expired` | None | + +Allowed subscription transitions are explicit and forward-only: + +| From | Allowed next state | +| ----------- | -------------------------------------------- | +| `pending` | `trialing`, `active`, `cancelled`, `expired` | +| `trialing` | `active`, `past_due`, `cancelled`, `expired` | +| `active` | `past_due`, `cancelled`, `expired` | +| `past_due` | `active`, `cancelled`, `expired` | +| `cancelled` | `expired` | +| `expired` | None | + +A same-state snapshot may update periods, product/catalog, interval, or +`cancel_at_period_end`. A plan/interval change does not invent another +subscription when the provider keeps the same subscription ID. Any other +transition is quarantined unless reconciliation proves the local row was +attached to the wrong provider identity; correcting identity is an audited +operator action, not an automatic fallback. + +An hourly deadline job clears `is_entitlement_source` and the active pointer +when verified cancellation paid-through time has elapsed, and records the +derived projection/audit transition. Checkout and entitlement resolution +perform the same expiry check under the organization lock, so a delayed job +cannot extend access or block a legitimate new checkout. Past-due grace expiry +keeps the paid feature projection but changes `canSend` to false; it does not +clear the source. A normal active subscription is not expired merely because a +provider API is temporarily unavailable or a local period timestamp passed. + +### `billing_provider_customers` + +Reuse a provider customer for the same authenticated payer: + +```text +id +provider +user_id FK -> user.id +provider_customer_id nullable while creating +idempotency_key +status creating | active | conflicted +last_error nullable +created_at / updated_at +``` + +Unique `(provider, user_id)`, `(provider, provider_customer_id)` when present, +and `idempotency_key` prevent duplicate customer records. Customer creation +uses the same durable-placeholder pattern as checkout: lock/create the local +`creating` row, call the provider outside the transaction with its stable +idempotency key, then attach the result. An ambiguous timeout is reconciled or +retried with that key, never with a fresh mutation. A subscription references +this row. Provider customer reuse must never be implemented by searching Dodo +by an arbitrary client-supplied email. + +Because a hosted customer portal may show every subscription owned by that +provider customer, only the stored billing-manager user can create its portal +session. Other organization owners can see plan status and upgrade guidance +but cannot open that payer's customer portal. In v1, a billing manager with a +nonterminal subscription cannot be removed or demoted and the organization +cannot be closed. Normal transfer is cancel-at-period-end followed by checkout +by the new owner after expiry. An emergency operator workflow may cancel the +old subscription and create a new customer/subscription only after verifying +both owners; it must record actor, reason, old/new identities, and timestamps. +It must not silently reassign a provider customer. + +### `billing_trial_claims` + +Store `user_id`, an HMAC of the normalized verified email at claim time, +canonical trial key (`pro_month`), organization ID, checkout-attempt ID, +`status` (`reserved | redeemed | released`), `expires_at`, and timestamps. + +- Acquire the claim in the same transaction/advisory lock that creates the + checkout attempt. Only one live or redeemed claim may exist per + `(user_id, trial_key)` and per `(verified_email_hmac, trial_key)`. +- A reservation expires with the checkout session after 24 hours and may be + released only when reconciliation confirms no provider subscription was + created. +- The claim becomes permanently `redeemed` as soon as a verified snapshot is + `trialing` or `active`, even if the subscription is immediately cancelled. +- Email changes do not update or delete historical HMACs. Store the HMAC key + version. Rotation computes the new-key HMAC for every historical claim and + atomically rebuilds the uniqueness index before the previous key is retired; + eligibility checks query all in-progress key versions. A rotation must never + create a window where the same email can claim again. + +### `billing_checkout_attempts` + +Checkout is a durable state machine because a hosted checkout can outlive an +HTTP request and each Dodo Checkout Session can create a new subscription: + +```text +id high-entropy public correlation ID +organization_id +payer_user_id +provider +catalog_key / requested_plan / requested_interval +billing_price_entry_id FK -> billing_price_entries.id +quoted_amount_minor / quoted_currency +billing_customer_id nullable until known +provider_checkout_session_id nullable +checkout_url_encrypted nullable, cleared at expiry/completion +idempotency_key server generated +status creating | open | completed | expired | + abandoned | conflicted +expires_at +last_error +created_at / updated_at / completed_at +``` + +Unique constraints cover `idempotency_key`, `(provider, +provider_checkout_session_id)`, and one nonterminal attempt per organization. +The service locks the organization and plan projection, rejects an existing +entitlement-bearing subscription, persists `creating`, then calls the provider +outside the transaction. It supplies provider idempotency when supported and +updates the row to `open`. A crash leaves a recoverable attempt; reconciliation +resumes it by attempt/idempotency key and never blindly creates another +session. Repeated client requests return the same unexpired checkout URL. + +Provider metadata contains this high-entropy attempt ID and canonical catalog +key, not an organization ID as the sole correlation mechanism. First +activation resolves the attempt and validates organization, payer, customer, +provider, product, and catalog. A conflicting second subscription is +quarantined and grants no entitlement until an operator resolves it. Retain +normalized attempt metadata for at least 13 months; provider-hosted URLs and +tokens are cleared at expiry and never logged. + +Allowed checkout transitions are: + +| From | Allowed next state | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `creating` | `open`, `expired`, `abandoned`, `conflicted` | +| `open` | `completed`, `expired`, `abandoned`, `conflicted` | +| `expired` | `completed` only when a verified subscription was created before the provider session expired; otherwise `conflicted` | +| `abandoned` | `conflicted` on any late subscription | +| `completed` / `conflicted` | None without an audited operator repair | + +Client retries never reopen terminal attempts. Reconciliation may perform the +narrow late `expired -> completed` transition only while the organization is +still pending/active for the same payer and has no entitlement source. + +### `billing_plan_change_attempts` + +Plan changes are durable provider mutations, not fire-and-forget requests: + +```text +id / change_id opaque public correlation ID +organization_id FK -> organizations.id +subscription_id FK -> organization_subscriptions.id +actor_user_id FK -> user.id +provider +idempotency_key unique, scoped to this organization and request +current_catalog_revision / current_price_entry_id +current_plan / current_interval +target_catalog_revision / target_price_entry_id +target_plan / target_interval +effective_at immediately | next_billing_date +proration_mode canonical SendLit policy value +provider_payment_id nullable +payment_url_encrypted nullable, cleared after completion/expiry +status creating | pending | succeeded | failed | conflicted +last_error +requested_at / completed_at / created_at / updated_at +``` + +There is at most one non-terminal plan-change attempt per organization. The +service locks the organization plan state, validates the current subscription +and active catalog revision, and inserts the attempt before calling the +provider. Provider calls use the attempt's stable idempotency key. An +ambiguous timeout leaves the attempt pending for reconciliation; it is never +blindly retried with a new key. A payment failure leaves the old plan active. +The verified webhook/reconciliation snapshot is the only authority that marks +the attempt succeeded and projects the new plan/interval. + +### `billing_webhook_events` + +Persist verified webhook ingress before applying it: + +```text +id +provider +provider_event_id +event_type +occurred_at +payload_encrypted nullable bytea/blob +payload_key_version nullable +status pending | processing | processed | ignored | + quarantined | failed +processing_attempts +last_error +available_at +locked_at / lease_expires_at / worker_id +received_at / processed_at +``` + +`failed` means a retryable processing failure awaiting `available_at`, while +`quarantined` requires operator review after retry exhaustion. Unique `(provider, +provider_event_id)` makes delivery idempotent. + +Workers claim rows in a transaction using `FOR UPDATE SKIP LOCKED` and a +five-minute lease. Retry transient failures after 1 minute, 5 minutes, 30 +minutes, 2 hours, and then every 8 hours with jitter, up to eight total +attempts; then quarantine and alert. An expired lease is reclaimable. Store a +redacted payload with authenticated envelope encryption under a billing-worker +key for 30 days, then erase it while retaining normalized +event identifiers, lifecycle result, timestamps, and audit history for 13 +months. Decryption is limited to the reconciliation/support command and is +audited. `last_error` is a bounded, sanitized code/message, never raw payload. +Never persist card data, checkout URLs, or full billing addresses. + +### Plan send usage + +Add organization-level monthly send buckets and per-outbound reservations, +modeled after the existing organization ESP quota implementation. A plan send +reservation applies to every delivery source and every message purpose. Each +reservation has `reserved | committed | released` state, a unique outbound +message ID, UTC bucket month, amount, and one-hour expiry. + +- The Free month is a UTC calendar month and resets at 00:00 UTC on the first + day of the next month. +- Create the outbound row, quota reservation, and dispatch-outbox record in + one database transaction. Include committed plus reserved usage in the + limit decision and use row locking/atomic conditional update so concurrent + sends cannot oversubscribe the bucket. +- Commit when the ESP accepts the message. +- Release when a recipient is suppressed before transport or the provider + synchronously rejects/finally fails the message. +- Retries reuse the same reservation through the existing outbound submission + identity; they never consume the plan twice. +- At the worker boundary, an existing reservation does not bypass the current + payment or reputation `canSend` decision. If its one-hour lease expired or + its month is no longer current, atomically release it and reserve against + the current UTC bucket before transport. This prevents end-of-month + reservation hoarding and double counting. +- A cleanup/reconciliation job releases stale reservations only after checking + the outbound message and dispatch lease. It commits accepted messages and + releases terminal non-accepted messages idempotently. + +The implementation may share primitives with `delivery/quota.ts`, but plan +usage must remain distinct from organization-mailbox grant quota. The Free +3,000 limit applies even when the team sends through its own ESP. + +## Billing-provider adapter + +Define the provider contract in a provider-neutral module. Exact names may +change, but the dependency direction may not: + +```ts +interface BillingProviderAdapter { + readonly provider: BillingProviderId; + readonly capabilities: { + planChanges: boolean; + intervalChanges: boolean; + portalPlanChanges: boolean; + portalIntervalChanges: boolean; + proratedPlanChanges: boolean; + }; + + createCustomer(input: CreateBillingCustomer): Promise; + createCheckout(input: CreateSubscriptionCheckout): Promise; + createPortalSession(input: CreatePortalSession): Promise; + changeSubscriptionPlan( + input: ChangeSubscriptionPlan, + ): Promise; + retrieveProduct(id: string): Promise; + retrieveSubscription(id: string): Promise; + parseWebhook(input: RawWebhookRequest): Promise; +} + +type ChangeSubscriptionPlan = { + providerSubscriptionId: string; + targetProviderProductId: string; + effectiveAt: "immediately" | "next_billing_date"; + prorationMode: "prorated_immediately" | "do_not_bill"; + idempotencyKey: string; +}; + +type PlanChangeResult = { + provider: BillingProviderId; + providerPaymentId: string | null; + paymentUrl: string | null; +}; +``` + +Canonical adapter outputs contain SendLit concepts only: + +```ts +type SubscriptionSnapshot = { + provider: BillingProviderId; + providerCustomerId: string; + providerSubscriptionId: string; + providerProductId: string; + status: "trialing" | "active" | "past_due" | "cancelled" | "expired"; + currentPeriodStartsAt: Date | null; + currentPeriodEndsAt: Date | null; + paidThroughAt: Date | null; + trialEndsAt: Date | null; + cancelAtPeriodEnd: boolean; + occurredAt: Date; + metadata: { sendlitCheckoutAttemptId?: string; catalogKey?: string }; +}; + +type BillingProductSnapshot = { + provider: BillingProviderId; + providerProductId: string; + currency: string; + amountMinor: number; + interval: BillingInterval; +}; +``` + +Rules: + +- Provider SDKs are imported only by `billing/providers//`. +- Routes and domain services depend on the adapter interface/registry, never a + Dodo class. +- Product IDs are translated through a server-side catalog map. Unknown + product IDs fail closed and alert; they do not silently become Free or a + higher plan. +- The adapter may expose capabilities, but provider limitations do not leak + into plan policy. +- Plan changes use `changeSubscriptionPlan`, never a provider portal redirect. + The adapter translates canonical effective-time and proration values to the + provider API. A provider that cannot change a live subscription exposes a + deterministic `unsupported` error; it must not silently change entitlements. +- Use a fake/in-memory adapter for domain and route tests. Every future + provider must pass the same adapter contract suite. +- Adapter calls use a 10-second deadline and normalized errors (`invalid`, + `unauthorized`, `conflict`, `rate_limited`, `unavailable`, `misconfigured`). + Read-only retrieval may retry up to three times with exponential backoff and + jitter. Customer or checkout creation may retry only with a provider + idempotency key or a proven lookup of the existing durable attempt; never + blindly retry a mutation after an ambiguous timeout. +- Do not use a provider's all-in-one Express handler as the application + architecture. The official provider SDK may be used inside the adapter for + API calls and signature verification. + +## Dodo Payments implementation + +Use the official `dodopayments` TypeScript SDK inside +`billing/providers/dodo/`. Dodo is responsible for hosted checkout, tax, +invoices, payment methods, and the customer portal. SendLit invokes Dodo's +subscription change-plan API for upgrades, downgrades, and interval changes; +the portal remains the destination for payment methods, invoices, +cancellation, and recovery. + +The Dodo customer portal must not be treated as a plan-change surface. If a +provider portal configuration offers subscription updates, Operations disables +that control (or removes the products from the portal's update collection) +while retaining the API capability used by SendLit. + +### Configuration + +```text +SENDLIT_DEPLOYMENT_MODE=cloud | oss +BILLING_CHECKOUT_PROVIDER=dodo +BILLING_ENABLED_PROVIDERS=dodo # comma-separated during migrations +BILLING_CATALOG_REVISION= +BILLING_CURRENCY= +BILLING_PRO_MONTH_AMOUNT_MINOR= +BILLING_PRO_YEAR_AMOUNT_MINOR= +BILLING_BUSINESS_MONTH_AMOUNT_MINOR= +BILLING_BUSINESS_YEAR_AMOUNT_MINOR= +DODO_PAYMENTS_API_KEY=... +DODO_PAYMENTS_WEBHOOK_KEY_CURRENT=... +DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS=... # optional, accepted for 48 hours +DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT=... +DODO_PAYMENTS_ENVIRONMENT=test_mode | live_mode +DODO_PRO_MONTH_PRODUCT_ID=... +DODO_PRO_YEAR_PRODUCT_ID=... +DODO_BUSINESS_MONTH_PRODUCT_ID=... +DODO_BUSINESS_YEAR_PRODUCT_ID=... +BILLING_RECENT_AUTH_MAX_AGE_SECONDS=900 +BILLING_DATA_ENCRYPTION_KEY= +BILLING_DATA_ENCRYPTION_KEY_VERSION=v1 +BILLING_DATA_ENCRYPTION_KEY_PREVIOUS=... # optional during rotation +``` + +OSS requires `SENDLIT_DEPLOYMENT_MODE=oss`, an empty enabled-provider set, and +no checkout provider. Cloud requires the checkout provider to be included in +the enabled set. Every provider referenced by a nonterminal subscription must +remain enabled for webhooks and reconciliation even after new checkout moves +elsewhere. Startup fails when these invariants are violated. Do not infer +cloud/OSS from `NODE_ENV`. + +Amount variables are provider-neutral and contain minor units (for example, +cents for a two-decimal currency). Parse them as base-10 integers, reject zero, +negative, fractional, exponent-form, whitespace-padded, or values above the +database/provider safe range, and never coerce with JavaScript floating-point +math. `BILLING_CURRENCY` is normalized/validated once and returned with every +offer. `.env.example` uses placeholders, not production amounts. + +Checkout Sessions are the required Dodo flow. Each session includes: + +- the server-selected product; +- quantity one; +- the configured, provider-verified billing currency and amount represented by + the selected product; SendLit does not trust or submit a browser amount; +- the authenticated, verified payer email or existing customer ID; +- an allowlisted return URL generated from the configured `WEB_CLIENT` origin, + including the public organization ID so the dashboard can select and poll + the correct organization after checkout; +- metadata containing the high-entropy checkout-attempt ID and catalog key; + the organization is resolved through that local attempt; and +- the 14-day trial only for an eligible `pro_month` selection. + +The return page says that activation is being confirmed and polls SendLit's +billing summary. Query-string success is never treated as payment proof. + +### Dodo event mapping + +At minimum, normalize: + +| Dodo event | Canonical action | +| --------------------------- | ------------------------------------------------- | +| `subscription.active` | Reconcile snapshot; activate trial/paid plan | +| `subscription.updated` | Reconcile the complete current snapshot | +| `subscription.renewed` | Keep active and advance period | +| `subscription.plan_changed` | Resolve product to plan/interval and update | +| `subscription.on_hold` | Mark past due and begin seven-day grace | +| `subscription.cancelled` | Respect immediate versus period-end cancellation | +| `subscription.expired` | Reconcile to Free after paid-through time | +| `subscription.failed` | Mark checkout/subscription failure; grant no plan | + +Payment events may be retained for diagnostics, but subscription events are +the primary lifecycle projection. Dodo currently emits the lifecycle events +above and recommends `subscription.updated` for full synchronization. + +### Webhook ingress and processing + +Mount provider-specific routes such as `POST /webhooks/billing/dodo` before +`express.json()` so each enabled adapter receives the exact raw bytes. This +mirrors the existing ESP webhook boundary and permits old and new providers to +coexist during migration. + +1. Enforce a 256 KiB raw-body limit and a generous provider-scoped ingress + rate limit that alerts before it rejects known-provider traffic. +2. Verify Dodo's Standard Webhooks signature and timestamp with the official + SDK helper. During secret rotation, accept current and unexpired previous + keys for at most 48 hours; record which key version verified the request. +3. Deduplicate using the `webhook-id` header. +4. Persist the verified event durably. Return 2xx only after insertion commits + or an existing verified duplicate is found. Return 5xx if persistence is + unavailable so the provider retries. +5. Process asynchronously using the inbox leases and retry schedule above. +6. Resolve the organization from the known subscription ID and checked + checkout-attempt metadata. Never accept an organization ID solely because + a payload contains it. +7. For every subscription lifecycle event, retrieve the provider's current + subscription snapshot before projecting it. The event is a wake-up signal, + not authoritative ordering. Validate provider/customer/subscription/product + identities and the retained catalog mapping. +8. Apply the subscription row, active-subscription pointer, plan projection, + checkout/trial state, and audit in one database transaction. Reprocessing an + unchanged snapshot is a no-op and does not duplicate audit events. +9. Unknown products, mismatched metadata, conflicting live subscriptions, + late activation of an abandoned organization, or impossible state + transitions are quarantined and alert. They never guess a plan. + +A five-second in-process timer also polls the durable webhook inbox and +settles expired send reservations. That timer uses a process-local running +guard so one instance cannot start another pass before the previous pass +finishes. Multi-instance correctness still depends on database claims and +row locks, not the in-process guard. + +Run reconciliation at least hourly for every nonterminal paid subscription, +every stale `creating`/`open` checkout attempt, and every pending plan-change +attempt, plus a daily sweep of recently terminal subscriptions for seven days. +Pending plan changes are retried with their original provider idempotency key; +the same key is safe after an ambiguous timeout. Reconciliation repairs missed +webhooks, records drift metrics, and uses exponential backoff during provider +outages. Ordinary product traffic continues from the last verified local +projection, except that a locally elapsed grace or paid-through deadline is +enforced without waiting for the provider. + +Reconciliation workers claim subscriptions/attempts with database leases and +`FOR UPDATE SKIP LOCKED`, just like webhook workers, so overlapping scheduler +runs cannot apply the same repair concurrently. Provider downtime backs off +per record and does not block reconciliation of other providers. + +Provider coexistence rules: + +- `BILLING_CHECKOUT_PROVIDER` chooses only new checkout sessions. +- `BILLING_ENABLED_PROVIDERS` controls adapter, webhook, and reconciliation + availability for all current and historical nonterminal subscriptions. +- Customer, attempt, subscription, webhook, and catalog records always carry + `provider`; no global provider assumption is permitted. +- Switching checkout provider requires enabling both providers, deploying + retained catalog mappings and webhook endpoints, switching the checkout + selector, and disabling the old adapter only after its last subscription is + terminal and retention obligations are met. + +## Entitlement enforcement architecture + +### Shared policy engine + +Create a framework-agnostic module, for example: + +```text +apps/api/src/billing/ + plans.ts + entitlements.ts + errors.ts + organization-plan-queries.ts + plan-usage.ts + provider.ts + provider-registry.ts + providers/dodo/* + webhooks/* + reconciliation/* +``` + +It exposes intention-revealing operations rather than plan-name checks: + +```ts +assertCapability(orgId, "shared_organization_mailbox"); +assertCapability(orgId, "provisioning"); +reserveTeamSlot(tx, orgId); +reserveSubscribedContactSlot(tx, orgId); +reserveSend(tx, { orgId, outboundMessageId, purpose }); +assertSendAllowed({ orgId, teamId, purpose }); +``` + +No route, MCP tool, or worker may contain checks such as +`plan === "business"`. It asks the policy engine about a capability or limit. + +### REST middleware + +Add middleware that resolves a plan context after authentication: + +- Team-scoped routes resolve organization ID from `req.teamId` after + `requireTeam`. +- Organization routes resolve the authorized public organization parameter to + the internal organization ID. +- Provisioning resolves organization ID from the organization key. +- The middleware attaches an immutable entitlement snapshot to the request + and supplies consistent plan-gate error mapping. + +Use route-level capability middleware where it is complete and safe, such as +mutating provisioning or shared-mailbox route groups. Do not rely on middleware +for counts, idempotent find-or-create behavior, worker sends, or MCP. + +### Domain guards + +Capacity writes must lock the organization plan-state row, calculate current +usage, and perform the protected insert/update in one transaction. This avoids +two concurrent requests both observing one remaining slot. + +Domain services are the final authority: + +- `createTeam` or a new guarded wrapper owns the team-limit transaction. +- Contact creation and `subscribed: false -> true` own the subscribed-contact + transaction. +- Transactional acceptance and campaign delivery own send reservations. +- Delivery-source resolution and the final transport boundary recheck shared + mailbox, payment, and sending-control eligibility. +- The provisioning domain guard applies even if a caller bypasses an Express + route in future code. + +Low-level unguarded insert helpers should be private to their module or require +an explicit trusted migration/bootstrap context so new call sites cannot +accidentally bypass policy. + +### Stable plan-gate errors + +Extend the shared API contract with a structured error: + +```json +{ + "error": "plan_limit_reached", + "error_description": "This organization has reached its 1-team Free plan limit.", + "organizationId": "org_...", + "plan": "free", + "capability": "teams", + "limit": 1, + "usage": 1, + "requiredPlan": "pro", + "upgradeUrl": "https://app.sendlit.example/organizations?..." +} +``` + +Use stable codes: + +- `plan_feature_unavailable` (403) +- `plan_limit_reached` (409) +- `payment_required` (402) +- `billing_owner_required` (403) +- `free_organization_already_owned` (409) +- `organization_name_already_exists` (409; case-insensitive among the user's + owned active/suspended/pending organizations) +- `billing_checkout_pending` (409) +- `billing_catalog_changed` (409) +- `billing_catalog_unavailable` (503) +- `active_subscription_exists` (409) +- `recent_authentication_required` (401) +- `domain_verification_required` (403) +- `sending_paused` (403, with non-sensitive reason and recovery guidance) + +Cleanup actions needed to get under a limit must never be blocked. MCP returns +the same code and guidance in its structured/error result instead of replacing +it with `internal_error`. + +## Gate matrix + +### Team capacity + +Guard every creation path: + +- `POST /teams` +- `POST /organizations/:organizationId/teams` +- MCP `create_team` +- `POST /provisioning/teams` +- default team creation during signup or paid-organization activation + +Idempotent provisioning replay for an existing `(organizationId, externalId)` +must succeed without reserving another slot. A new external ID uses a slot. + +### Subscribed contacts + +Guard: + +- `POST /contacts` when it would create a subscribed row; +- `PATCH /contacts/:contactId` when it changes `subscribed` from false to true; +- MCP `create_contact` and `update_contact`; +- every present or future import/sync/upsert path; and +- automation actions that can create or re-subscribe contacts. + +Find-or-create of an already-existing contact consumes no new slot. Creating a +transactional email never creates a subscribed contact. + +### Free monthly sends + +Apply the organization reservation to campaign, sequence, and transactional +mail through both organization and team ESPs. For a broadcast, preflight the +estimated audience against remaining Free usage and reject before fan-out when +the estimate exceeds it; still reserve each recipient atomically because the +audience can change. + +Recheck `canSend` and the reservation immediately before transport. Work +queued before a payment failure, downgrade, Free cap, or reputation stop must +not leak through a worker. + +### Shared organization mailbox + +Free blocks mutating operations under: + +- `/organizations/:organizationId/esps` +- organization ESP feedback configuration; +- `/organizations/:organizationId/delivery-policy` when enabling an + organization source; and +- `/organizations/:organizationId/teams/:teamId/esp-grant`. + +Reads, retirement/revocation, and deletion needed for cleanup remain allowed. +`resolveDeliverySource`, `resolvePinnedDeliverySource`, campaign workers, and +transactional workers must reject actual use when the feature is unavailable. + +### Provisioning and organization API keys + +Only Business and OSS can create organization API keys or perform mutating +provisioning operations: + +- provision team; +- update provisioned team; +- replace integration keys; +- suspend/resume; and +- archive through the provisioning API. + +Read-only provisioned-team metadata and usage may remain available after a +downgrade to support export and diagnosis, but no organization key can create +or mutate resources. Authentication does not itself confer the capability. + +### Free organization creation and ownership + +Guard `POST /organizations`, paid-organization checkout creation, adding an +owner, and promoting a member/admin to owner. Perform the ownership check and +membership write in one transaction after locking the affected user rows (in +stable ID order for multi-owner changes) or taking equivalent per-user +advisory locks. Account deletion/email-change flows must not cascade away +organization ownership or trial-claim history in a way that creates a bypass; +ownership must be transferred or the organization deliberately closed first. + +### Marketing branding + +The Free “Sent with SendLit” mark is injected server-side at marketing render +time using the current effective plan. It is not persisted as user-editable +template content and is never included in transactional mail. + +Extend the managed footer render context in `@sendlit/email-blocks` with an +optional server-owned branding value. The API renderer supplies it; client +payload validation must reject attempts to set it. An upgrade removes the mark +on the next render, and a downgrade adds it without rewriting stored +templates. OSS always renders without the mark. + +## Fair use and cloud sending safety + +Fair use is not a per-email price. It is a separate sending-control policy +that applies to Pro and Business and uses the existing outbound ledger and +feedback events. + +### Reputation windows and actions + +Evaluate each team over a rolling seven-day window after at least 500 accepted +messages: + +| Signal | Action | +| ------------------------------------------------------ | ------------------------------------------------------------------------- | +| Bounce rate >= 2% or complaint rate >= 0.05% | Warn owners/admins | +| Bounce rate >= 5% or complaint rate >= 0.1% | Pause broadcasts and sequences; allow transactional at the degraded limit | +| Complaint rate >= 0.3% | Stop all new sends for that team | +| 10 complaints in seven days, regardless of denominator | Stop all new sends for that team | + +The default degraded transactional allowance is 100 accepted messages per UTC +day per team. Keep it in policy configuration so Operations can alter it +without editing route logic. + +The denominator is unique outbound messages accepted by an ESP in the rolling +seven-day window. The bounce numerator is unique accepted outbound messages +whose normalized final delivery state is bounced; retries and duplicate +provider events count once. The complaint numerator is unique accepted +outbound messages with a normalized complaint. Only events correlated to an +organization/team/outbound identity enter rates; uncorrelated events alert and +enter a separate diagnostic counter rather than being assigned to a guessed +team. The absolute ten-complaint rule uses the same deduplicated complaints. + +Persist team-specific sending-control state rather than overloading the plan +or deleting scheduled work: + +```text +team_id unique +status normal | warned | marketing_paused | all_paused +reason_code nullable +source automatic | operator +entered_at / evaluated_at +minimum_hold_until nullable +operator_user_id / operator_reason / overridden_at nullable +``` + +When a team uses a shared organization mailbox, a reputation stop suspends +that grant for that team only. It does not pause the mailbox for every team. +The 100/day degraded transactional allowance uses its own atomic daily bucket +and reservation lifecycle. + +Recovery uses hysteresis: + +- `warned` returns to normal only after seven consecutive daily evaluations + below both warning thresholds. +- `marketing_paused` has a minimum 72-hour hold and returns to normal only + after seven consecutive daily evaluations below both warning thresholds. +- `all_paused`, including the ten-complaint rule, never auto-recovers; an + operator must review the sending source/list, record a reason, and release + it. A release starts at `warned`, not directly at an unobserved clean state. +- A new threshold breach resets the recovery streak. An operator may impose a + stricter state but cannot override an active payment stop. + +Recalculate after processed bounce/complaint events and at least hourly. Every +automatic warning, pause, recovery, and operator override is audited. An admin +UI is not required for v1, but an operator command and owner notification are. + +### Paid-organization ramp + +Apply a configurable marketing-only daily ramp from first paid activation: + +- days 0-2: 200/day; +- days 3-6: 1,000/day; +- days 7-13: 10,000/day; and +- day 14 onward: no plan send cap, subject to fair use. + +Transactional sends do not consume the marketing ramp, but remain subject to +payment and reputation controls. A plan or interval change does not restart a +clean organization's ramp. Cancellation followed by a later reactivation may +resume from historical clean tenure unless Operations reset it for abuse. + +Persist organization ramp state (`first_paid_activated_at`, current stage, +clean stage days, evaluated timestamp, and optional audited operator reset) +and atomic UTC daily marketing reservations. A stage advances only after its +required clean days with no fair-use warning/pause; `warned` freezes +advancement and either pause blocks marketing regardless of unused ramp. The +worker rechecks and commits/releases the daily reservation with the same +outbound identity used for plan usage, so concurrent campaigns cannot exceed +the ramp and retries do not double count. + +### Verification + +- Cloud sending requires at least one verified organization owner email. +- Before an organization leaves test volume, its From domain must be verified. + Implement provider-independent DNS verification rather than assuming that a + configured SMTP credential proves domain ownership. +- Use 100 accepted lifetime cloud messages per organization as the initial + configurable test-volume threshold. After that, unverified domains are + blocked with a verification error and setup link. +- Plain SMTP remains available for OSS and cloud tests but cannot unlock paid + fair-use volume without a reviewed bounce/complaint feedback connection. + +Persist organization-scoped sending domains: + +```text +id / public_id +organization_id +domain normalized lowercase IDNA ASCII +challenge_token_hash +status pending | verified | revoked | failed +verified_at / last_checked_at / next_check_at +failed_check_count / first_failed_at +created_at / updated_at +``` + +Unique `(organization_id, domain)`. The challenge is 32 random bytes exposed +once and verified through a TXT record at +`_sendlit-verification.`; store only its keyed hash. Reject public +suffixes, IP literals, wildcard input, and domains outside normal DNS length +rules. Verification performs DNS TXT lookup only—never an arbitrary HTTP +callback—using bounded resolver timeouts. Recheck verified domains every 30 +days and revoke only after three failed checks spanning at least 72 hours, with +owner warning before enforcement. Transient failures retain a verified domain +until that threshold is met. A From address above test volume must match the +exact verified domain or a separately verified subdomain. + +Add owner/admin REST contracts to list domains, create a challenge, request a +verification refresh, and revoke a domain; expose read-only verification state +to relevant MCP send errors, but do not expose the challenge through MCP in +v1. The Organizations UI provides the DNS instructions and status. This may +be a separate implementation slice, but paid unlimited/fair-use marketing is +not launch-complete before this gate and automated reputation stops are live. + +## REST API contract + +Add provider-neutral schemas and routes to `@sendlit/api-contract` so OpenAPI +is generated from the same source. + +### Read active billing catalog + +`GET /billing/catalog` + +This is a public, read-only, normally rate-limited route. It returns the active +`catalogRevision`, currency, and the four offers with +`catalogKey`, `plan`, `interval`, `amountMinor`, and `trialDays`. It never +returns provider/product IDs. The web application and any public pricing +surface use this response rather than bundled numeric constants. Responses may +use an ETag and at most five minutes of public caching; checkout still validates +the submitted revision, so stale display data cannot authorize an old price. +If no fully verified catalog is active in cloud mode, return a stable +`billing_catalog_unavailable` error and hide/disable checkout rather than +displaying fallback amounts. + +### Read billing summary + +`GET /organizations/:organizationId/billing` + +Organization members may read non-sensitive plan and usage information. +Owners/admins receive management flags. Never return provider IDs. + +```json +{ + "plan": "pro", + "billingInterval": "month", + "paymentStatus": "active", + "trialEndsAt": null, + "currentPeriodEndsAt": "...", + "cancelAtPeriodEnd": false, + "graceEndsAt": null, + "canManageBilling": true, + "entitlements": { + "teamsLimit": 5, + "subscribedContactsLimit": 10000, + "monthlySendsLimit": null, + "sharedOrganizationMailbox": true, + "provisioning": false, + "marketingBranding": false + }, + "usage": { + "teams": 2, + "subscribedContacts": 8120, + "monthlySends": 19440 + }, + "pendingPlanChange": null +} +``` + +### Sensitive billing action authorization + +`POST /billing/action-token` + +Body: `{ action, target }`, where `action` is one of +`organization_checkout`, `checkout`, `portal`, `plan_change`, or +`organization_close`. Existing-organization actions bind `target` to the +public organization ID; paid organization creation uses `new`. + +The endpoint requires a first-party human session created within +`BILLING_RECENT_AUTH_MAX_AGE_SECONDS`, an exact allowlisted Origin, and the +double-submit CSRF token. It returns a random, five-minute, single-use token +bound to the user, session, action, and target. The caller sends it in +`X-Sendlit-Billing-Action-Token` on the corresponding mutation. Expired, +replayed, cross-action, and cross-organization tokens fail closed. A stale +session must complete the hosted email-OTP sign-in again before a token can be +issued. + +### Existing-organization checkout + +`POST /organizations/:organizationId/billing/checkout` + +Body: `{ plan: "pro" | "business", interval: "month" | "year", +catalogRevision: number }`. + +Requires a human organization owner with a verified email. It creates/reuses +the payer's provider customer, validates trial eligibility, prevents a second +active or nonterminal checkout/subscription, and returns `{ checkoutUrl, +expiresAt }`. A repeated request with the same selection returns the durable +open attempt. The checkout URL is short-lived and never accepted from a client +on a later API call. A stale revision returns `409 billing_catalog_changed` +with the new catalog metadata and creates no customer, attempt, or provider +session. + +### Organization plan change + +`POST /organizations/:organizationId/billing/plan-change` + +Body: `{ plan: "pro" | "business", interval: "month" | "year", +catalogRevision: number, idempotencyKey?: string }`. + +Only the billing manager may request a change. SendLit resolves the target +offer from the verified active catalog and applies the default policy: +upgrades (including monthly to yearly) are immediate and prorated; downgrades +(including yearly to monthly) are scheduled for the next billing date without +an immediate charge. The request is persisted before the provider mutation +and uses the adapter's stable idempotency key. The response is `202` with an +opaque `changeId`, `status: "pending"`, the effective time, and an optional +short-lived payment URL if the provider requires an additional payment step. +The old entitlements remain in force until a signed webhook or reconciliation +snapshot confirms the target product. Repeating a request with the same +idempotency key returns the original attempt; a different target while an +attempt is pending returns `409 billing_plan_change_pending`. + +`GET /organizations/:organizationId/billing/plan-changes/:changeId` returns +the redacted attempt status (`pending`, `succeeded`, `failed`, or +`conflicted`) and effective time. It never returns provider IDs, secrets, or +unredacted provider errors. + +### Paid organization creation + +`POST /billing/organization-checkouts` + +Body: `{ organizationName, teamName, plan, interval, catalogRevision }`. + +Creates a `pending_payment` organization, owner membership, plan state, and +checkout. The verified activation webhook creates the first team +idempotently, marks the organization active, and makes it selectable. Failed +or abandoned pending organizations expose a resume-checkout/hide flow only to +their creator; hiding tombstones the pending organization but retains billing +correlation records. + +### Customer portal + +`POST /organizations/:organizationId/billing/portal` + +Requires the stored billing-manager user and returns a short-lived +`{ portalUrl }`. It does not proxy portal content through SendLit. It is used +for payment methods, invoices, cancellation, and recovery, not for plan or +interval changes. The +provider session's return URL is generated from the configured `WEB_CLIENT` +origin and includes the public organization ID, so a user who owns multiple +organizations returns to the organization whose billing they just managed. + +### Webhook + +`POST /webhooks/billing/dodo` + +Public, raw-body, signature-authenticated, rate-limited independently, and not +part of the normal user/API-key auth middleware. + +### Usage + +Add `GET /organizations/:organizationId/plan-usage`; do not alter the existing +shared-delivery usage contract. It returns teams, subscribed contacts, +calendar-month SendLit plan usage, reservation/committed totals, bucket +boundaries, and relevant limits without provider identifiers. + +## MCP parity + +Implementation is incomplete if REST is gated but MCP can bypass it. + +- `create_team`, `create_contact`, `update_contact`, transactional send, + sequence start/enrollment, and every future mutation call the guarded domain + services. +- Map plan errors to structured MCP errors with the same stable code, plan, + usage, limit, required plan, and upgrade URL. +- Update tool descriptions where a plan limit or capability is relevant. +- Add one read-only `get_plan_usage` tool for the current team's parent + organization so an MCP client can explain a denial. It must not expose + provider customer/subscription IDs or billing portal links. +- Do not expose checkout, payment method, invoice, cancellation, or portal + tools through MCP in v1. +- Extend MCP policy/registry tests so every mutating tool has both scope and + entitlement coverage. + +## Web application requirements + +All standard components must use shadcn/ui and be installed with the Shadcn +CLI when a component is not already present. + +### Organizations area + +- Show the selected organization's plan badge, payment state, team usage, + contact usage, and Free send usage. +- Keep organization billing actions together in the Organizations **Plan** tab. + A contextual upgrade action beside a team may link to that tab, but its + label/copy must say it upgrades the parent organization and its request must + use the organization ID. +- Owners see **Upgrade** on Free, **Change plan** on paid plans, and + **Manage billing** when they are the billing manager. +- Admin/member users may see the plan and limits but never a provider portal + for somebody else's billing customer. +- Upgrade opens a plan/interval dialog, summarizes the organization being + upgraded, and redirects to hosted checkout. The dialog fetches the active + billing catalog and formats `amountMinor`/currency through one shared money + formatter; no paid amount appears as a React/translation constant. +- Change plan opens the same catalog-backed dialog with the current selection + highlighted. SendLit submits the target plan/interval to its plan-change API, + shows the effective date and pending confirmation, and only redirects to an + optional provider payment link when the adapter requires one. Manage billing + remains available separately for invoices, cards, cancellation, and payment + recovery. +- Pass the displayed `catalogRevision` to checkout. On + `billing_catalog_changed`, refresh the dialog and require the owner to review + the new amount before retrying; never redirect automatically after a price + change. +- Returning from checkout shows **Confirming subscription** and refreshes the + billing summary until a verified event activates it. It must not optimistically + unlock features. +- Past-due and cancellation-at-period-end banners include exact dates and the + appropriate portal action. +- Over-limit banners show current usage, the limit, allowed cleanup actions, + and upgrade guidance. + +### New organization flow + +- In an OSS deployment, omit plan selection entirely and create the new + organization as OSS implicitly after the user enters its name. +- If the user owns no Free organization, the dialog offers Free, Pro, or + Business. +- If the user already owns a Free organization, Free is unavailable and the + dialog requires Pro or Business plus interval before continuing to checkout. +- Organization names are unique case-insensitively among the user's owned + active, suspended, or pending organizations; closed or abandoned names may be + reused. +- Free organization creation creates the organization and its initial team in + one transaction. When no explicit team name is supplied, the initial team is + named from the organization (for example, `Acme Team`) instead of the generic + `Default Team`. +- After successful organization creation, reload the dashboard so the + organization and team switchers reflect the new organization immediately. +- Paid creation shows pending status until activation; abandoned pending rows + can be resumed while an attempt is valid or hidden/tombstoned after expiry. + +### Account page + +Remove the account-level Free-plan billing card and any copy saying billing is +for the account. Replace the Billing tab with a short explanation and link to +Organizations, or remove the tab entirely. Accounts are never billed. + +### Feature surfaces + +- Disable or replace New team, New shared mailbox, grant, organization key, + and provisioning actions using the server-returned entitlement snapshot. +- Client-side disabling is explanatory only. The API/domain guards remain the + authority. +- Free marketing previews show the SendLit mark that the server will inject. + +## Security and reliability requirements + +- Require a verified human session for checkout, plan changes, and portal creation. Team and + organization API keys cannot access billing endpoints. +- Only organization owners can start a subscription; only the stored billing + manager can change a plan or open the customer portal. +- Checkout, portal, cancellation/close, and any future billing-manager action + require authentication within the last 15 minutes. Otherwise require a + verified-email OTP/WebAuthn reauthentication and issue a single-purpose, + five-minute server action token bound to user, organization, action, and + session. A normal long-lived session is insufficient. +- Cookie-authenticated billing mutations require the application's CSRF token + and an exact allowlisted `Origin` (with a same-origin `Referer` fallback only + where the browser omits Origin). Provider webhook routes are exempt from CSRF + because they use raw-body signatures and are mounted separately. +- Organization billing and billing-mutation responses set `Cache-Control: +no-store` and `Referrer-Policy: no-referrer`. The non-sensitive public catalog + is the sole cacheable exception and follows its five-minute/ETag contract. + Checkout and portal URLs are returned only in response bodies, never placed + in application logs, analytics, error reports, or referrer parameters. +- Never log API keys, webhook secrets, checkout URLs, portal URLs, full billing + payloads, addresses, or payment details. +- Use raw-body signature verification and reject stale/invalid Dodo webhook + timestamps. +- Allowlist checkout return URLs; never accept an arbitrary redirect from the + browser. +- Store only opaque provider IDs. Card, tax, invoice, and billing-address data + remain at the provider. +- Use unique constraints and transactions for checkout activation, first-team + creation, trial claims, plan-change attempts, webhook deduplication, and + provider subscription attachment. +- A replayed active event must not create another team or audit duplicate plan + transitions. +- A webhook naming an unknown organization, customer, product, or conflicting + subscription is quarantined and alerts Operations; it never grants access. +- Provider API outage does not downgrade active customers. New checkout and + plan-change/portal requests return a retryable provider-unavailable response; + an ambiguous plan-change timeout remains pending for reconciliation. +- Organization close returns `409 active_subscription_exists` while any + subscription is nonterminal or has future paid-through entitlement, and + `409 billing_checkout_pending` while a live checkout attempt exists. Close + requires the billing action token. The owner must cancel in the provider + portal and wait for expiry; DELETE never performs a surprising remote + cancellation. Pending organizations may only be tombstoned after their + attempts expire/are abandoned, retaining billing correlation for late-event + quarantine. +- A billing manager cannot be removed/demoted while responsible for a + nonterminal subscription. Ownership mutation and subscription checks occur + under the same organization lock. +- Provider credentials, webhook secrets, and email-HMAC keys live in the + deployment secret store, are never database-configurable through public API, + and are exposed only to billing/webhook worker processes that need them. + +## Observability and operations + +Record structured metrics/events for: + +- checkout requested, created, failed, and returned; +- subscription activated, changed, renewed, past due, recovered, cancelled, + and expired; +- webhook verified, duplicate, ignored, failed, retried, and processing lag; +- reconciliation success, drift, and provider failure; +- plan-gate denial by capability, plan, surface (REST/MCP/worker), and org; +- usage reservation, commit, release, stale cleanup, and rejected overage; +- fair-use warning/pause/recovery; and +- time from verified paid event to entitlement availability. + +Add an operator command or script to: + +- inspect/verify an environment catalog revision and abandon a reverted + pending/invalid revision with an audit reason; +- inspect and reconcile one organization subscription; +- set/remove team and contact overrides with an audit reason; +- retry/quarantine a webhook event; +- perform the documented emergency cancel-and-recreate billing-manager + recovery after dual identity verification; +- apply/release a reputation sending control. + +Never require direct unaudited database edits for ordinary recovery. + +Initial alerts/SLOs: + +- page when verified webhook inbox oldest-pending age exceeds five minutes for + 10 minutes, any event is quarantined, or signature failures spike above the + normal baseline; +- alert when a nonterminal subscription has not reconciled for six hours, a + `creating` checkout/customer is unresolved for 15 minutes, or provider drift + is detected; +- alert before raw-payload purge, reservation cleanup, deadline, domain + recheck, or reputation-evaluation jobs miss two scheduled runs; and +- dashboard provider latency/error rate, entitlement projection lag, active + past-due grace deadlines, and reservation drift. + +## Migration and rollout + +### Schema/backfill + +Use expand/backfill/validate/enforce/contract migrations. New code must tolerate +nullable/unbackfilled rows during rollout; backfill in small batches, create +large indexes concurrently where PostgreSQL permits, and validate constraints +before enforcement. Do not combine provider webhook cutover, destructive column +removal, and plan enforcement in one deployment. + +1. Add price entry/catalog revision, plan projection, subscription history, + provider customer, checkout attempt, plan-change attempt, trial claim, + webhook inbox, usage reservation, sending-control, and sending-domain + tables plus organization `pending_payment`/`abandoned` status. +2. Create a Free plan-state row whenever a cloud organization is created. +3. Backfill existing organizations without deleting or moving data. +4. Do not blindly make known CourseLit/FrontLit platform organizations Free. + Before enforcement, provide an explicit deployment manifest or operator + script that assigns Business and any negotiated overrides to those public + organization IDs. +5. In OSS mode, stored cloud plan projections are ignored and effective plan + is OSS. +6. Report every existing cloud org above Free limits before enforcement. + +### Cloud enforcement + +Cloud always applies plan gates. OSS mode remains unrestricted. + +### Delivery order + +1. Canonical catalog, schema, plan resolver, structured errors, and fake + adapter +2. Transaction-safe team/contact/send guards across REST, MCP, and workers +3. OSS/cloud mode +4. Dodo customer, checkout, plan-change, portal, raw webhook, and reconciliation adapter +5. Organization billing/usage REST contracts and generated OpenAPI +6. Organizations UI, checkout return state, new paid-org flow, and account + billing cleanup +7. Render-time Free branding +8. Reputation automation, paid ramp, account/domain verification, and + notifications +9. Production backfill and Dodo test-mode acceptance + +Items 1-8 are launch requirements for publicly claiming the complete pricing +model. A controlled billing beta may begin after item 6 if send volumes are +manually restricted and Operations is actively reviewing feedback. + +## Testing strategy + +### Unit and contract tests + +- Every plan policy and override combination +- Environment catalog parsing rejects absent, fractional, negative, unsafe, + malformed, or unsupported-currency amounts +- Public catalog exposes configured minor-unit amounts/currency but never + provider IDs; no web/server plan definition contains a paid numeric constant +- Provider catalog verification rejects amount, currency, interval, and product + mismatches +- Payment-status-to-effective-entitlement transitions, including exact grace + boundaries and period-end cancellation +- Canonical catalog to Dodo product mapping and unknown product rejection +- Billing-provider contract suite against fake and Dodo adapters +- Trial reservation, expiry/release, permanent redemption, email-change + resistance, and concurrent claims +- Canonical subscription transitions, duplicate `on_hold` without grace + extension, paid-through boundaries, and historical subscription isolation +- Plan error serialization for REST and MCP +- Free branding present only on Free marketing renders + +### Database/integration tests + +- A four-offer catalog revision activates atomically; a partial/invalid or lower + rolling-deploy revision cannot replace the current catalog +- A price revision leaves old checkout/subscription snapshots and reverse + webhook mappings intact +- A stale checkout revision creates no customer/session and returns the new + catalog for explicit user review +- Two concurrent last-slot team creations produce one success and one stable + limit error +- Concurrent contact creation/re-subscription cannot exceed the org pool +- The same email in two teams counts twice +- Transactional-only recipients do not affect contact usage +- Concurrent Free send reservations cannot exceed 3,000 +- Retry/idempotency reuses one send reservation; an expired or previous-month + reservation is atomically moved before transport +- Two concurrent checkout requests create one durable provider session and a + crash between local attempt creation and provider response is reconcilable +- A billing manager with a nonterminal subscription cannot be removed, + demoted, or bypassed by another owner +- Concurrent and repeated plan-change requests create one provider mutation; + an ambiguous provider timeout is reconciled without a second mutation +- Downgrade preserves rows and blocks only the specified new actions +- Cleanup/export/unsubscribe/delete remain possible while over limit +- Organization-key provisioning cannot bypass Business entitlement +- MCP and REST reach identical guard decisions +- Past-due queued work is stopped at the worker boundary after grace + +### Webhook tests + +- Invalid signature and stale timestamp rejected +- Current and time-bounded previous webhook keys verify during rotation +- Duplicate event acknowledged without duplicate mutation +- Out-of-order event cannot revert a newer state +- Unknown product/subscription/org quarantined +- Verified ingress returns 5xx when the durable inbox cannot commit +- Expired worker leases are reclaimed; retry exhaustion quarantines and alerts +- Activation creates the pending org's first team exactly once +- Late activation for an abandoned org and a conflicting second live + subscription grant no entitlement +- Plan change, recovery, scheduled cancellation, immediate cancellation, and + expiry project correctly +- Reconciliation repairs a deliberately dropped webhook +- During provider migration, old-provider webhooks update only their + subscription while new checkout uses the configured new provider + +### Security and policy tests + +- Billing mutations reject API keys, stale authentication, missing/invalid + CSRF, and cross-origin requests +- Portal creation rejects an owner who is not the stored billing manager +- Billing responses are no-store and URLs/secrets are redacted from logs +- Production cloud refuses missing/mismatched mode/provider/catalog config +- Fair-use rates deduplicate retries/events; pause recovery obeys minimum hold + and clean-evaluation streaks; all-pause requires operator release +- Concurrent marketing sends cannot exceed the paid ramp; a warning freezes + stage advancement and retries reuse the same daily reservation +- Domain verification rejects public suffixes/IP/wildcards, stores no plaintext + challenge, and enforces the exact verified From domain after test volume + +### Browser and provider acceptance + +- Use Dodo test mode for all four catalog products +- Confirm each amount displayed by the web app comes from the active catalog, + matches hosted checkout, and changes after environment/catalog revision + update without a code change +- Keep an old subscription active across a new-price catalog deployment and + verify it is not silently repriced +- Complete eligible/ineligible trial checkouts +- Verify organization-specific checkout and return polling +- Change Pro ↔ Business and monthly ↔ yearly from the SendLit dialog; verify + the provider subscription changes and the webhook-backed projection updates +- Open the payer-only customer portal; update card, cancel, and recover payment +- Confirm Organizations team-item Upgrade targets the parent organization +- Confirm account Billing no longer implies account-level charging +- Exercise over-limit, past-due, and downgrade UI states +- Run API and Web with `pnpm dev:api` and `pnpm dev:web`; keep Postgres, Redis, + and Mailpit running; use headful Chrome DevTools for smoke testing + +## Documentation requirements + +Implementation changes under `apps/api` must update both REST/OpenAPI and MCP. + +- Add all billing/plan schemas and routes to `packages/api-contract` so + `openapi.json` remains generated rather than hand-maintained. +- Document every catalog environment variable in the deployment guide and + `.env.example` with placeholders, minor-unit semantics, revision procedure, + provider verification, rollback behavior, and the rule that paid amounts are + never source constants. +- Update developer error documentation with stable plan-gate codes. +- Update provisioning docs to state Business/OSS eligibility and downgrade + behavior. +- Update organization/team docs with plan inheritance, usage, billing-manager + permissions, and the one-Free-org rule. +- Update MCP docs/tool descriptions and document `get_plan_usage` plus plan + error behavior. +- Publish separate customer-facing billing FAQ and acceptable-use/deliverability + policy derived from the product-marketing pricing source. Do not publish this + internal architecture PRD as the pricing page. + +## Expected implementation areas + +| Area | Expected files/modules | +| ----------------- | --------------------------------------------------------------------------------------------------------------------- | +| Schema | `apps/api/src/db/schema.ts`, Drizzle migration and snapshots | +| Billing domain | new `apps/api/src/billing/**` catalog, entitlements, usage, provider registry, Dodo adapter, webhooks, reconciliation | +| Configuration | validated environment schema, placeholder `.env.example`, catalog revision loader/verifier | +| Auth/context | plan-context middleware adjacent to `requireAuth` / `requireTeam` | +| Organization/team | guarded organization creation, ownership changes, team creation, billing routes and queries | +| Contacts | guarded create and re-subscribe paths | +| Sending | transactional acceptance, sequence/broadcast start and workers, outbound reservation lifecycle | +| Shared delivery | organization ESP/grant/policy routes and final delivery-source resolution | +| Contract/OpenAPI | `packages/api-contract/src/schemas/**`, `contract.ts`, validation tests | +| MCP | guarded tools, policy/error mapping, `get_plan_usage`, tool tests | +| Web | Organizations plan/usage/upgrade/new-org flows, API wrappers, account billing cleanup | +| Email blocks | server-owned optional SendLit branding in the managed marketing footer | +| Public docs | Organizations, teams, provisioning, MCP, authentication/errors, billing FAQ, acceptable use | + +## Acceptance criteria + +1. With `SENDLIT_DEPLOYMENT_MODE=oss` and no enabled/checkout provider, every + organization resolves to OSS, all product features work, no SendLit plan + limits or branding apply, and no checkout/portal is shown. Missing or + contradictory production configuration fails startup. +2. In cloud mode, signup produces one Free organization and one team with the + published limits. +3. A user cannot create or acquire a second Free organization, but invitations + remain unlimited. +4. An organization owner can buy Pro or Business monthly/yearly through Dodo; + Pro monthly receives a one-time eligible 14-day trial. +5. The stored billing manager can change plan and interval from SendLit; the + provider portal is used only for payment methods, invoices, cancellation, + and recovery. +6. Verified subscription state updates only the targeted organization and is + visible in the Organizations area without migrating its teams or data. +7. The same payer reuses one Dodo customer, while each paid organization has a + distinct subscription. +8. Dodo-specific code is confined to its adapter and can be replaced by a fake + or second provider without changing plan policy or public billing contracts. +9. Every team, contact, send, shared-mailbox, organization-key, and provisioning + gate is enforced across REST, MCP, and background execution with stable + upgrade guidance. +10. Concurrent requests cannot exceed team, contact, or Free send limits. +11. Downgrade and payment failure never delete data and match the documented + cleanup, grace, and sending behavior. +12. Free marketing email contains server-owned SendLit branding; OSS, Pro, + Business, and transactional mail do not. +13. Webhooks are raw-body verified, idempotent, out-of-order safe, audited, and + repairable through reconciliation. +14. The Organizations UI owns billing. The Account UI does not imply that a + login has a plan or subscription. +15. Automated fair-use stops, paid ramp, verified ownership/domain gates, and + team-specific shared-mailbox isolation are live before unrestricted paid + cloud sending is advertised. +16. REST/OpenAPI, MCP, web UI, public docs, tests, and operational tooling agree + on the same plan catalog and error semantics. +17. Repeated/concurrent checkout cannot create duplicate entitlement-bearing + subscriptions, and checkout/trial state recovers after process failure. +18. Existing subscriptions remain operable while the checkout provider is + migrated; late events from a historical subscription cannot replace the + active projection. +19. Billing mutations require recent human authentication and CSRF protection; + billing-manager removal, organization close, webhook key rotation, inbox + leases, retention, and quarantine follow the defined lifecycle. +20. Paid amounts/currency are accepted from validated environment configuration, + verified against provider products, served by the billing-catalog contract, + and absent from application/UI constants. Raising a price and catalog + revision requires no source-code change and does not reprice existing + subscriptions implicitly. + +## Risks and mitigations + +| Risk | Mitigation | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| REST middleware gives a false sense of complete enforcement | Mandatory shared domain guards and worker-boundary checks; MCP parity tests | +| Concurrent writes exceed a cap | Lock plan state and mutate in one transaction; atomic send reservations | +| Missing billing configuration accidentally unlocks OSS or disables gates | Explicit deployment mode, startup invariants, and cloud plan-gate enforcement | +| Repeated or ambiguous checkout creates duplicate subscriptions | Durable checkout attempt, mutation idempotency, one source constraint, reconciliation, conflict quarantine | +| Provider webhook is missed, duplicated, or reordered | Durable verified inbox, unique provider event ID, event-time checks, snapshot reconciliation | +| Provider switch strands existing subscribers | Per-record provider identity, multiple enabled adapters, retained legacy catalog maps, migration runbook | +| UI amount differs from hosted checkout | Verified environment catalog, revision-bound checkout, stale-revision rejection, provider mismatch stop | +| Dodo concepts spread through the product | Provider interface, canonical snapshots/events, server catalog mapping, adapter contract tests | +| Customer portal exposes another payer's subscriptions | Portal only for stored billing-manager user; plan changes use SendLit API; no org-admin impersonation | +| Repeated or ambiguous plan change mutates twice | Durable plan-change attempt, provider idempotency, one nonterminal attempt, webhook/reconciliation source | +| Existing platform org is accidentally downgraded | Explicit production backfill manifest, override audit | +| Downgrade leaks sends already queued | Recheck payment, capability, usage, and reputation immediately before transport | +| Full OSS competes with cloud | UI/docs clearly sell managed hosting, upgrades, monitoring, security maintenance, and worker operations | +| “Fair use” exists only as copy | Launch gate requires measured thresholds, automatic controls, notifications, and audit | + +## External provider references + +The Dodo implementation assumptions above were checked against the provider's +official documentation on 2026-08-28: + +- [Subscription integration and Checkout Sessions](https://docs.dodopayments.com/developer-resources/subscription-integration-guide) +- [Subscription webhook lifecycle events](https://docs.dodopayments.com/developer-resources/webhooks/intents/subscription) +- [Customer Portal](https://docs.dodopayments.com/features/customer-portal) +- [TypeScript SDK](https://docs.dodopayments.com/developer-resources/sdks/typescript) +- [Metadata](https://docs.dodopayments.com/api-reference/metadata) +- [Webhook signature, idempotency, and ordering guidance](https://docs.dodopayments.com/developer-resources/webhooks) + +Provider behavior must be revalidated against current official documentation +when implementation begins; the adapter contract and SendLit policy remain the +stable parts. diff --git a/apps/api/drizzle/0005_many_shape.sql b/apps/api/drizzle/0005_many_shape.sql new file mode 100644 index 0000000..7da260b --- /dev/null +++ b/apps/api/drizzle/0005_many_shape.sql @@ -0,0 +1,495 @@ +CREATE TABLE IF NOT EXISTS "billing_catalog_revision_items" ( + "id" uuid PRIMARY KEY NOT NULL, + "catalog_revision_id" uuid NOT NULL, + "catalog_key" text NOT NULL, + "billing_price_entry_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_catalog_revisions" ( + "id" uuid PRIMARY KEY NOT NULL, + "revision" integer NOT NULL, + "checkout_provider" text NOT NULL, + "status" text DEFAULT 'pending_verification' NOT NULL, + "verified_at" timestamp with time zone, + "activated_at" timestamp with time zone, + "retired_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_catalog_revisions_revision_unique" UNIQUE("revision"), + CONSTRAINT "billing_catalog_revisions_status_check" CHECK ("billing_catalog_revisions"."status" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')), + CONSTRAINT "billing_catalog_revisions_revision_check" CHECK ("billing_catalog_revisions"."revision" > 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_checkout_attempts" ( + "id" uuid PRIMARY KEY NOT NULL, + "attempt_id" text NOT NULL, + "organization_id" uuid NOT NULL, + "payer_user_id" text NOT NULL, + "provider" text NOT NULL, + "catalog_revision" integer NOT NULL, + "catalog_key" text NOT NULL, + "requested_plan" text NOT NULL, + "requested_interval" text NOT NULL, + "billing_price_entry_id" uuid NOT NULL, + "quoted_amount_minor" integer NOT NULL, + "quoted_currency" text NOT NULL, + "billing_customer_id" uuid, + "provider_checkout_session_id" text, + "checkout_url_encrypted" text, + "idempotency_key" text NOT NULL, + "status" text DEFAULT 'creating' NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "last_error" text, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_checkout_attempts_attempt_id_unique" UNIQUE("attempt_id"), + CONSTRAINT "billing_checkout_attempts_status_check" CHECK ("billing_checkout_attempts"."status" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')), + CONSTRAINT "billing_checkout_attempts_amount_check" CHECK ("billing_checkout_attempts"."quoted_amount_minor" > 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_price_entries" ( + "id" uuid PRIMARY KEY NOT NULL, + "catalog_key" text NOT NULL, + "plan" text NOT NULL, + "billing_interval" text NOT NULL, + "currency" text NOT NULL, + "amount_minor" integer NOT NULL, + "provider" text NOT NULL, + "provider_product_id" text NOT NULL, + "verified_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_price_entries_amount_check" CHECK ("billing_price_entries"."amount_minor" > 0), + CONSTRAINT "billing_price_entries_currency_check" CHECK ("billing_price_entries"."currency" ~ '^[A-Z]{3}$'), + CONSTRAINT "billing_price_entries_plan_check" CHECK ("billing_price_entries"."plan" IN ('pro', 'business')), + CONSTRAINT "billing_price_entries_interval_check" CHECK ("billing_price_entries"."billing_interval" IN ('month', 'year')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_provider_customers" ( + "id" uuid PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "user_id" text NOT NULL, + "provider_customer_id" text, + "idempotency_key" text NOT NULL, + "status" text DEFAULT 'creating' NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_provider_customers_status_check" CHECK ("billing_provider_customers"."status" IN ('creating', 'active', 'conflicted')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_trial_claims" ( + "id" uuid PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "verified_email_fingerprint" text NOT NULL, + "fingerprint_key_version" text NOT NULL, + "trial_key" text NOT NULL, + "organization_id" uuid NOT NULL, + "checkout_attempt_id" uuid, + "status" text DEFAULT 'reserved' NOT NULL, + "expires_at" timestamp with time zone, + "redeemed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_trial_claims_status_check" CHECK ("billing_trial_claims"."status" IN ('reserved', 'redeemed', 'released')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_webhook_events" ( + "id" uuid PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "provider_event_id" text NOT NULL, + "event_type" text NOT NULL, + "occurred_at" timestamp with time zone NOT NULL, + "payload_encrypted" text, + "payload_key_version" text, + "status" text DEFAULT 'pending' NOT NULL, + "processing_attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "available_at" timestamp with time zone DEFAULT now() NOT NULL, + "locked_at" timestamp with time zone, + "lease_expires_at" timestamp with time zone, + "worker_id" text, + "received_at" timestamp with time zone DEFAULT now() NOT NULL, + "processed_at" timestamp with time zone, + CONSTRAINT "billing_webhook_events_status_check" CHECK ("billing_webhook_events"."status" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organization_plan_states" ( + "id" uuid PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "plan" text DEFAULT 'free' NOT NULL, + "active_subscription_id" uuid, + "teams_limit_override" integer, + "contacts_limit_override" integer, + "projection_version" integer DEFAULT 0 NOT NULL, + "first_paid_activated_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "organization_plan_states_organization_id_unique" UNIQUE("organization_id"), + CONSTRAINT "organization_plan_states_plan_check" CHECK ("organization_plan_states"."plan" IN ('free', 'pro', 'business')), + CONSTRAINT "organization_plan_states_teams_override_check" CHECK ("organization_plan_states"."teams_limit_override" IS NULL OR "organization_plan_states"."teams_limit_override" > 0), + CONSTRAINT "organization_plan_states_contacts_override_check" CHECK ("organization_plan_states"."contacts_limit_override" IS NULL OR "organization_plan_states"."contacts_limit_override" > 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organization_subscriptions" ( + "id" uuid PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "billing_customer_id" uuid NOT NULL, + "billing_manager_user_id" text NOT NULL, + "provider" text NOT NULL, + "provider_subscription_id" text NOT NULL, + "provider_product_id" text NOT NULL, + "billing_price_entry_id" uuid NOT NULL, + "catalog_key" text NOT NULL, + "plan" text NOT NULL, + "billing_interval" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "current_period_starts_at" timestamp with time zone, + "current_period_ends_at" timestamp with time zone, + "paid_through_at" timestamp with time zone, + "trial_ends_at" timestamp with time zone, + "past_due_at" timestamp with time zone, + "grace_ends_at" timestamp with time zone, + "cancel_at_period_end" boolean DEFAULT false NOT NULL, + "is_entitlement_source" boolean DEFAULT false NOT NULL, + "last_provider_event_at" timestamp with time zone, + "last_reconciled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "organization_subscriptions_status_check" CHECK ("organization_subscriptions"."status" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')), + CONSTRAINT "organization_subscriptions_plan_check" CHECK ("organization_subscriptions"."plan" IN ('pro', 'business')), + CONSTRAINT "organization_subscriptions_interval_check" CHECK ("organization_subscriptions"."billing_interval" IN ('month', 'year')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "plan_send_reservations" ( + "id" uuid PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "outbound_message_id" uuid NOT NULL, + "bucket_id" uuid NOT NULL, + "amount" integer DEFAULT 1 NOT NULL, + "state" text DEFAULT 'reserved' NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "committed_at" timestamp with time zone, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plan_send_reservations_amount_check" CHECK ("plan_send_reservations"."amount" > 0), + CONSTRAINT "plan_send_reservations_state_check" CHECK ("plan_send_reservations"."state" IN ('reserved', 'committed', 'released')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "plan_send_usage_buckets" ( + "id" uuid PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "bucket_month" timestamp with time zone NOT NULL, + "committed" integer DEFAULT 0 NOT NULL, + "reserved" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plan_send_usage_buckets_count_check" CHECK ("plan_send_usage_buckets"."committed" >= 0 AND "plan_send_usage_buckets"."reserved" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sending_domains" ( + "id" uuid PRIMARY KEY NOT NULL, + "domain_id" text NOT NULL, + "organization_id" uuid NOT NULL, + "domain" text NOT NULL, + "challenge_token_hash" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "verified_at" timestamp with time zone, + "last_checked_at" timestamp with time zone, + "next_check_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sending_domains_domain_id_unique" UNIQUE("domain_id"), + CONSTRAINT "sending_domains_domain_id_check" CHECK ("sending_domains"."domain_id" ~ '^domain_'), + CONSTRAINT "sending_domains_status_check" CHECK ("sending_domains"."status" IN ('pending', 'verified', 'revoked', 'failed')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "team_sending_controls" ( + "id" uuid PRIMARY KEY NOT NULL, + "team_id" uuid NOT NULL, + "status" text DEFAULT 'normal' NOT NULL, + "reason_code" text, + "source" text DEFAULT 'automatic' NOT NULL, + "entered_at" timestamp with time zone, + "evaluated_at" timestamp with time zone, + "minimum_hold_until" timestamp with time zone, + "operator_user_id" text, + "operator_reason" text, + "overridden_at" timestamp with time zone, + "clean_evaluation_days" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "team_sending_controls_team_id_unique" UNIQUE("team_id"), + CONSTRAINT "team_sending_controls_status_check" CHECK ("team_sending_controls"."status" IN ('normal', 'warned', 'marketing_paused', 'all_paused')), + CONSTRAINT "team_sending_controls_source_check" CHECK ("team_sending_controls"."source" IN ('automatic', 'operator')), + CONSTRAINT "team_sending_controls_clean_days_check" CHECK ("team_sending_controls"."clean_evaluation_days" >= 0) +); +--> statement-breakpoint +-- Backfill the provider-neutral Free projection for organizations created by +-- older migrations. The deterministic UUID is only used for this one-time +-- backfill; all new rows use the application UUIDv7 generator. +INSERT INTO "organization_plan_states" ("id", "organization_id", "plan") +SELECT md5("organizations"."id"::text || ':organization-plan-state')::uuid, + "organizations"."id", + 'free' +FROM "organizations" +WHERE NOT EXISTS ( + SELECT 1 + FROM "organization_plan_states" AS "existing" + WHERE "existing"."organization_id" = "organizations"."id" +); +--> statement-breakpoint +ALTER TABLE "organizations" DROP CONSTRAINT "organizations_status_check";--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_catalog_revision_items" ADD CONSTRAINT "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk" FOREIGN KEY ("catalog_revision_id") REFERENCES "public"."billing_catalog_revisions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_catalog_revision_items" ADD CONSTRAINT "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_payer_user_id_user_id_fk" FOREIGN KEY ("payer_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("billing_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_provider_customers" ADD CONSTRAINT "billing_provider_customers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk" FOREIGN KEY ("checkout_attempt_id") REFERENCES "public"."billing_checkout_attempts"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_active_subscription_id_organization_subscriptions_id_fk" FOREIGN KEY ("active_subscription_id") REFERENCES "public"."organization_subscriptions"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_billing_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("billing_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_billing_manager_user_id_user_id_fk" FOREIGN KEY ("billing_manager_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plan_send_reservations" ADD CONSTRAINT "plan_send_reservations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plan_send_reservations" ADD CONSTRAINT "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk" FOREIGN KEY ("bucket_id") REFERENCES "public"."plan_send_usage_buckets"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plan_send_usage_buckets" ADD CONSTRAINT "plan_send_usage_buckets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sending_domains" ADD CONSTRAINT "sending_domains_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "team_sending_controls" ADD CONSTRAINT "team_sending_controls_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "team_sending_controls" ADD CONSTRAINT "team_sending_controls_operator_user_id_user_id_fk" FOREIGN KEY ("operator_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revision_items_revision_key_uidx" ON "billing_catalog_revision_items" USING btree ("catalog_revision_id","catalog_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revision_items_revision_price_uidx" ON "billing_catalog_revision_items" USING btree ("catalog_revision_id","billing_price_entry_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revisions_active_provider_uidx" ON "billing_catalog_revisions" USING btree ("checkout_provider") WHERE "billing_catalog_revisions"."status" = 'active';--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_provider_session_uidx" ON "billing_checkout_attempts" USING btree ("provider","provider_checkout_session_id") WHERE "billing_checkout_attempts"."provider_checkout_session_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_idempotency_uidx" ON "billing_checkout_attempts" USING btree ("idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_organization_nonterminal_uidx" ON "billing_checkout_attempts" USING btree ("organization_id") WHERE "billing_checkout_attempts"."status" IN ('creating', 'open');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_price_entries_provider_product_uidx" ON "billing_price_entries" USING btree ("provider","provider_product_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "billing_price_entries_catalog_key_idx" ON "billing_price_entries" USING btree ("catalog_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_provider_user_uidx" ON "billing_provider_customers" USING btree ("provider","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_provider_customer_uidx" ON "billing_provider_customers" USING btree ("provider","provider_customer_id") WHERE "billing_provider_customers"."provider_customer_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_idempotency_uidx" ON "billing_provider_customers" USING btree ("idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_user_trial_uidx" ON "billing_trial_claims" USING btree ("user_id","trial_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_email_trial_uidx" ON "billing_trial_claims" USING btree ("verified_email_fingerprint","trial_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_webhook_events_provider_event_uidx" ON "billing_webhook_events" USING btree ("provider","provider_event_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "billing_webhook_events_queue_idx" ON "billing_webhook_events" USING btree ("status","available_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organization_subscriptions_provider_subscription_uidx" ON "organization_subscriptions" USING btree ("provider","provider_subscription_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "organization_subscriptions_organization_source_uidx" ON "organization_subscriptions" USING btree ("organization_id") WHERE "organization_subscriptions"."is_entitlement_source" = true;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plan_send_reservations_outbound_uidx" ON "plan_send_reservations" USING btree ("outbound_message_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plan_send_reservations_expiry_idx" ON "plan_send_reservations" USING btree ("state","expires_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plan_send_usage_buckets_organization_month_uidx" ON "plan_send_usage_buckets" USING btree ("organization_id","bucket_month");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "sending_domains_organization_domain_uidx" ON "sending_domains" USING btree ("organization_id","domain");--> statement-breakpoint +ALTER TABLE "organizations" ADD CONSTRAINT "organizations_status_check" CHECK ("organizations"."status" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')); +--> statement-breakpoint +ALTER TABLE "billing_checkout_attempts" ADD COLUMN "pending_team_name" text; +--> statement-breakpoint +ALTER TABLE "organization_plan_states" ADD COLUMN "ramp_stage" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint +ALTER TABLE "organization_plan_states" ADD COLUMN "ramp_clean_stage_days" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint +ALTER TABLE "organization_plan_states" ADD COLUMN "ramp_evaluated_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_ramp_stage_check" CHECK ("organization_plan_states"."ramp_stage" BETWEEN 0 AND 3); +--> statement-breakpoint +ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_ramp_clean_days_check" CHECK ("organization_plan_states"."ramp_clean_stage_days" >= 0); +--> statement-breakpoint +DROP INDEX IF EXISTS "billing_trial_claims_user_trial_uidx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "billing_trial_claims_email_trial_uidx"; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_user_trial_uidx" ON "billing_trial_claims" USING btree ("user_id","trial_key") WHERE "billing_trial_claims"."status" <> 'released'; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_email_trial_uidx" ON "billing_trial_claims" USING btree ("verified_email_fingerprint","trial_key") WHERE "billing_trial_claims"."status" <> 'released'; +--> statement-breakpoint +ALTER TABLE "sending_domains" ADD COLUMN "failed_check_count" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint +ALTER TABLE "sending_domains" ADD COLUMN "first_failed_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "sending_domains" ADD CONSTRAINT "sending_domains_failed_check_count_check" CHECK ("sending_domains"."failed_check_count" >= 0); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_plan_change_attempts" ( + "id" uuid PRIMARY KEY NOT NULL, + "change_id" text NOT NULL, + "organization_id" uuid NOT NULL, + "subscription_id" uuid NOT NULL, + "actor_user_id" text NOT NULL, + "provider" text NOT NULL, + "idempotency_key" text NOT NULL, + "current_catalog_revision" integer NOT NULL, + "current_billing_price_entry_id" uuid NOT NULL, + "current_plan" text NOT NULL, + "current_interval" text NOT NULL, + "target_catalog_revision" integer NOT NULL, + "target_billing_price_entry_id" uuid NOT NULL, + "target_plan" text NOT NULL, + "target_interval" text NOT NULL, + "effective_at" text NOT NULL, + "proration_mode" text NOT NULL, + "provider_payment_id" text, + "payment_url_encrypted" text, + "status" text DEFAULT 'creating' NOT NULL, + "last_error" text, + "requested_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_plan_change_attempts_change_id_unique" UNIQUE("change_id"), + CONSTRAINT "billing_plan_change_attempts_status_check" CHECK ("billing_plan_change_attempts"."status" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')), + CONSTRAINT "billing_plan_change_attempts_effective_at_check" CHECK ("billing_plan_change_attempts"."effective_at" IN ('immediately', 'next_billing_date')), + CONSTRAINT "billing_plan_change_attempts_proration_mode_check" CHECK ("billing_plan_change_attempts"."proration_mode" IN ('prorated_immediately', 'do_not_bill')), + CONSTRAINT "billing_plan_change_attempts_current_plan_check" CHECK ("billing_plan_change_attempts"."current_plan" IN ('pro', 'business')), + CONSTRAINT "billing_plan_change_attempts_target_plan_check" CHECK ("billing_plan_change_attempts"."target_plan" IN ('pro', 'business')), + CONSTRAINT "billing_plan_change_attempts_current_interval_check" CHECK ("billing_plan_change_attempts"."current_interval" IN ('month', 'year')), + CONSTRAINT "billing_plan_change_attempts_target_interval_check" CHECK ("billing_plan_change_attempts"."target_interval" IN ('month', 'year')), + CONSTRAINT "billing_plan_change_attempts_revision_check" CHECK ("billing_plan_change_attempts"."current_catalog_revision" > 0 AND "billing_plan_change_attempts"."target_catalog_revision" > 0) +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_subscription_id_organization_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."organization_subscriptions"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("current_billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("target_billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_plan_change_attempts_idempotency_uidx" ON "billing_plan_change_attempts" USING btree ("idempotency_key"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_plan_change_attempts_organization_nonterminal_uidx" ON "billing_plan_change_attempts" USING btree ("organization_id") WHERE "billing_plan_change_attempts"."status" IN ('creating', 'pending'); diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..74b16da --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,9013 @@ +{ + "id": "a594d9b3-210f-4f32-8659-fc030231be4f", + "prevId": "8853889e-feb0-41bf-a7ab-bc9b4d0c5fcc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "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 with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "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": { + "auth_account_user_id_idx": { + "name": "auth_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_account_issuer_account_id_uidx": { + "name": "auth_account_issuer_account_id_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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": false + }, + "public.billing_catalog_revision_items": { + "name": "billing_catalog_revision_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "catalog_revision_id": { + "name": "catalog_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "catalog_key": { + "name": "catalog_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "billing_catalog_revision_items_revision_key_uidx": { + "name": "billing_catalog_revision_items_revision_key_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "catalog_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_catalog_revision_items_revision_price_uidx": { + "name": "billing_catalog_revision_items_revision_price_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_price_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk": { + "name": "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_catalog_revisions", + "columnsFrom": ["catalog_revision_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_price_entries", + "columnsFrom": ["billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revisions": { + "name": "billing_catalog_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checkout_provider": { + "name": "checkout_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_verification'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_catalog_revisions_active_provider_uidx": { + "name": "billing_catalog_revisions_active_provider_uidx", + "columns": [ + { + "expression": "checkout_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_catalog_revisions\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_catalog_revisions_revision_unique": { + "name": "billing_catalog_revisions_revision_unique", + "nullsNotDistinct": false, + "columns": ["revision"] + } + }, + "policies": {}, + "checkConstraints": { + "billing_catalog_revisions_status_check": { + "name": "billing_catalog_revisions_status_check", + "value": "\"billing_catalog_revisions\".\"status\" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')" + }, + "billing_catalog_revisions_revision_check": { + "name": "billing_catalog_revisions_revision_check", + "value": "\"billing_catalog_revisions\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_checkout_attempts": { + "name": "billing_checkout_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_user_id": { + "name": "payer_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "catalog_key": { + "name": "catalog_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_plan": { + "name": "requested_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_interval": { + "name": "requested_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pending_team_name": { + "name": "pending_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quoted_amount_minor": { + "name": "quoted_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quoted_currency": { + "name": "quoted_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_checkout_session_id": { + "name": "provider_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url_encrypted": { + "name": "checkout_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_checkout_attempts_provider_session_uidx": { + "name": "billing_checkout_attempts_provider_session_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"provider_checkout_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_idempotency_uidx": { + "name": "billing_checkout_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_organization_nonterminal_uidx": { + "name": "billing_checkout_attempts_organization_nonterminal_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_checkout_attempts_organization_id_organizations_id_fk": { + "name": "billing_checkout_attempts_organization_id_organizations_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_payer_user_id_user_id_fk": { + "name": "billing_checkout_attempts_payer_user_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": ["payer_user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": ["billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_provider_customers", + "columnsFrom": ["billing_customer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_checkout_attempts_attempt_id_unique": { + "name": "billing_checkout_attempts_attempt_id_unique", + "nullsNotDistinct": false, + "columns": ["attempt_id"] + } + }, + "policies": {}, + "checkConstraints": { + "billing_checkout_attempts_status_check": { + "name": "billing_checkout_attempts_status_check", + "value": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')" + }, + "billing_checkout_attempts_amount_check": { + "name": "billing_checkout_attempts_amount_check", + "value": "\"billing_checkout_attempts\".\"quoted_amount_minor\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_change_attempts": { + "name": "billing_plan_change_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "change_id": { + "name": "change_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_catalog_revision": { + "name": "current_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_billing_price_entry_id": { + "name": "current_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_plan": { + "name": "current_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_interval": { + "name": "current_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_catalog_revision": { + "name": "target_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_billing_price_entry_id": { + "name": "target_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_plan": { + "name": "target_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_interval": { + "name": "target_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proration_mode": { + "name": "proration_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_id": { + "name": "provider_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_url_encrypted": { + "name": "payment_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_plan_change_attempts_idempotency_uidx": { + "name": "billing_plan_change_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_plan_change_attempts_organization_nonterminal_uidx": { + "name": "billing_plan_change_attempts_organization_nonterminal_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_plan_change_attempts_organization_id_organizations_id_fk": { + "name": "billing_plan_change_attempts_organization_id_organizations_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_subscription_id_organization_subscriptions_id_fk": { + "name": "billing_plan_change_attempts_subscription_id_organization_subscriptions_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "organization_subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_actor_user_id_user_id_fk": { + "name": "billing_plan_change_attempts_actor_user_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": ["current_billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": ["target_billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_change_attempts_change_id_unique": { + "name": "billing_plan_change_attempts_change_id_unique", + "nullsNotDistinct": false, + "columns": ["change_id"] + } + }, + "policies": {}, + "checkConstraints": { + "billing_plan_change_attempts_status_check": { + "name": "billing_plan_change_attempts_status_check", + "value": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')" + }, + "billing_plan_change_attempts_effective_at_check": { + "name": "billing_plan_change_attempts_effective_at_check", + "value": "\"billing_plan_change_attempts\".\"effective_at\" IN ('immediately', 'next_billing_date')" + }, + "billing_plan_change_attempts_proration_mode_check": { + "name": "billing_plan_change_attempts_proration_mode_check", + "value": "\"billing_plan_change_attempts\".\"proration_mode\" IN ('prorated_immediately', 'do_not_bill')" + }, + "billing_plan_change_attempts_current_plan_check": { + "name": "billing_plan_change_attempts_current_plan_check", + "value": "\"billing_plan_change_attempts\".\"current_plan\" IN ('pro', 'business')" + }, + "billing_plan_change_attempts_target_plan_check": { + "name": "billing_plan_change_attempts_target_plan_check", + "value": "\"billing_plan_change_attempts\".\"target_plan\" IN ('pro', 'business')" + }, + "billing_plan_change_attempts_current_interval_check": { + "name": "billing_plan_change_attempts_current_interval_check", + "value": "\"billing_plan_change_attempts\".\"current_interval\" IN ('month', 'year')" + }, + "billing_plan_change_attempts_target_interval_check": { + "name": "billing_plan_change_attempts_target_interval_check", + "value": "\"billing_plan_change_attempts\".\"target_interval\" IN ('month', 'year')" + }, + "billing_plan_change_attempts_revision_check": { + "name": "billing_plan_change_attempts_revision_check", + "value": "\"billing_plan_change_attempts\".\"current_catalog_revision\" > 0 AND \"billing_plan_change_attempts\".\"target_catalog_revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_price_entries": { + "name": "billing_price_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "catalog_key": { + "name": "catalog_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_price_entries_provider_product_uidx": { + "name": "billing_price_entries_provider_product_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_entries_catalog_key_idx": { + "name": "billing_price_entries_catalog_key_idx", + "columns": [ + { + "expression": "catalog_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_price_entries_amount_check": { + "name": "billing_price_entries_amount_check", + "value": "\"billing_price_entries\".\"amount_minor\" > 0" + }, + "billing_price_entries_currency_check": { + "name": "billing_price_entries_currency_check", + "value": "\"billing_price_entries\".\"currency\" ~ '^[A-Z]{3}$'" + }, + "billing_price_entries_plan_check": { + "name": "billing_price_entries_plan_check", + "value": "\"billing_price_entries\".\"plan\" IN ('pro', 'business')" + }, + "billing_price_entries_interval_check": { + "name": "billing_price_entries_interval_check", + "value": "\"billing_price_entries\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_provider_customers": { + "name": "billing_provider_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_provider_customers_provider_user_uidx": { + "name": "billing_provider_customers_provider_user_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_provider_customer_uidx": { + "name": "billing_provider_customers_provider_customer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_provider_customers\".\"provider_customer_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_idempotency_uidx": { + "name": "billing_provider_customers_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_provider_customers_user_id_user_id_fk": { + "name": "billing_provider_customers_user_id_user_id_fk", + "tableFrom": "billing_provider_customers", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_provider_customers_status_check": { + "name": "billing_provider_customers_status_check", + "value": "\"billing_provider_customers\".\"status\" IN ('creating', 'active', 'conflicted')" + } + }, + "isRLSEnabled": false + }, + "public.billing_trial_claims": { + "name": "billing_trial_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_email_fingerprint": { + "name": "verified_email_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_key_version": { + "name": "fingerprint_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trial_key": { + "name": "trial_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_trial_claims_user_trial_uidx": { + "name": "billing_trial_claims_user_trial_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trial_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_trial_claims\".\"status\" <> 'released'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_trial_claims_email_trial_uidx": { + "name": "billing_trial_claims_email_trial_uidx", + "columns": [ + { + "expression": "verified_email_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trial_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_trial_claims\".\"status\" <> 'released'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_trial_claims_user_id_user_id_fk": { + "name": "billing_trial_claims_user_id_user_id_fk", + "tableFrom": "billing_trial_claims", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_trial_claims_organization_id_organizations_id_fk": { + "name": "billing_trial_claims_organization_id_organizations_id_fk", + "tableFrom": "billing_trial_claims", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_trial_claims", + "tableTo": "billing_checkout_attempts", + "columnsFrom": ["checkout_attempt_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_trial_claims_status_check": { + "name": "billing_trial_claims_status_check", + "value": "\"billing_trial_claims\".\"status\" IN ('reserved', 'redeemed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_key_version": { + "name": "payload_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_webhook_events_provider_event_uidx": { + "name": "billing_webhook_events_provider_event_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_queue_idx": { + "name": "billing_webhook_events_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_webhook_events_status_check": { + "name": "billing_webhook_events_status_check", + "value": "\"billing_webhook_events\".\"status\" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.contact_custom_field_values": { + "name": "contact_custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_type": { + "name": "value_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_text": { + "name": "value_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_number": { + "name": "value_number", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "value_boolean": { + "name": "value_boolean", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "value_date": { + "name": "value_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "contact_custom_field_values_contact_key_idx": { + "name": "contact_custom_field_values_contact_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_text_lookup_idx": { + "name": "contact_custom_field_values_text_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_number_lookup_idx": { + "name": "contact_custom_field_values_number_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_boolean_lookup_idx": { + "name": "contact_custom_field_values_boolean_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_boolean", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_date_lookup_idx": { + "name": "contact_custom_field_values_date_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_custom_field_values_team_id_teams_id_fk": { + "name": "contact_custom_field_values_team_id_teams_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_custom_field_values_contact_id_contacts_id_fk": { + "name": "contact_custom_field_values_contact_id_contacts_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribed": { + "name": "subscribed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "contacts_team_id_email_idx": { + "name": "contacts_team_id_email_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_team_id_teams_id_fk": { + "name": "contacts_team_id_teams_id_fk", + "tableFrom": "contacts", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contacts_contact_id_unique": { + "name": "contacts_contact_id_unique", + "nullsNotDistinct": false, + "columns": ["contact_id"] + }, + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": ["unsubscribe_token"] + } + }, + "policies": {}, + "checkConstraints": { + "contacts_contact_id_check": { + "name": "contacts_contact_id_check", + "value": "\"contacts\".\"contact_id\" ~ '^cnt_'" + } + }, + "isRLSEnabled": false + }, + "public.email_deliveries": { + "name": "email_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_deliveries_team_id_teams_id_fk": { + "name": "email_deliveries_team_id_teams_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_sequence_id_sequences_id_fk": { + "name": "email_deliveries_sequence_id_sequences_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_contact_id_contacts_id_fk": { + "name": "email_deliveries_contact_id_contacts_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_email_id_sequence_emails_id_fk": { + "name": "email_deliveries_email_id_sequence_emails_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequence_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_delivery_events": { + "name": "email_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_key": { + "name": "provider_event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bounce_class": { + "name": "bounce_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_code": { + "name": "smtp_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enhanced_status_code": { + "name": "enhanced_status_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_mta": { + "name": "remote_mta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "email_delivery_events_connection_id_provider_event_key_idx": { + "name": "email_delivery_events_connection_id_provider_event_key_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_delivery_events_team_id_occurred_at_idx": { + "name": "email_delivery_events_team_id_occurred_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_delivery_events_outbound_message_id_idx": { + "name": "email_delivery_events_outbound_message_id_idx", + "columns": [ + { + "expression": "outbound_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk": { + "name": "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_webhook_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_connection_id_esp_feedback_connections_id_fk": { + "name": "email_delivery_events_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_team_id_teams_id_fk": { + "name": "email_delivery_events_team_id_teams_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_outbound_message_id_outbound_messages_id_fk": { + "name": "email_delivery_events_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_delivery_events_event_id_unique": { + "name": "email_delivery_events_event_id_unique", + "nullsNotDistinct": false, + "columns": ["event_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_delivery_events_event_id_check": { + "name": "email_delivery_events_event_id_check", + "value": "\"email_delivery_events\".\"event_id\" ~ '^evt_'" + } + }, + "isRLSEnabled": false + }, + "public.email_events": { + "name": "email_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_index": { + "name": "link_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bounce_type": { + "name": "bounce_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bounce_reason": { + "name": "bounce_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_events_team_id_teams_id_fk": { + "name": "email_events_team_id_teams_id_fk", + "tableFrom": "email_events", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_sequence_id_sequences_id_fk": { + "name": "email_events_sequence_id_sequences_id_fk", + "tableFrom": "email_events", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_contact_id_contacts_id_fk": { + "name": "email_events_contact_id_contacts_id_fk", + "tableFrom": "email_events", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_email_id_sequence_emails_id_fk": { + "name": "email_events_email_id_sequence_emails_id_fk", + "tableFrom": "email_events", + "tableTo": "sequence_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppression_actions": { + "name": "email_suppression_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "suppression_id": { + "name": "suppression_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppression_actions_suppression_id_created_at_idx": { + "name": "email_suppression_actions_suppression_id_created_at_idx", + "columns": [ + { + "expression": "suppression_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_suppression_actions_team_id_teams_id_fk": { + "name": "email_suppression_actions_team_id_teams_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppression_actions_suppression_id_email_suppressions_id_fk": { + "name": "email_suppression_actions_suppression_id_email_suppressions_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_suppressions", + "columnsFrom": ["suppression_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppression_actions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppression_actions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_delivery_events", + "columnsFrom": ["source_event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_suppression_actions_actor_user_id_user_id_fk": { + "name": "email_suppression_actions_actor_user_id_user_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "user", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppressions": { + "name": "email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "suppression_id": { + "name": "suppression_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_hash": { + "name": "recipient_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_key_version": { + "name": "hash_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "first_suppressed_at": { + "name": "first_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_suppressed_at": { + "name": "last_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_team_id_recipient_hash_idx": { + "name": "email_suppressions_team_id_recipient_hash_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_suppressions_team_id_active_idx": { + "name": "email_suppressions_team_id_active_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_suppressions_team_id_teams_id_fk": { + "name": "email_suppressions_team_id_teams_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppressions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppressions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "email_delivery_events", + "columnsFrom": ["source_event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_suppressions_released_by_user_id_fk": { + "name": "email_suppressions_released_by_user_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "user", + "columnsFrom": ["released_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_suppressions_suppression_id_unique": { + "name": "email_suppressions_suppression_id_unique", + "nullsNotDistinct": false, + "columns": ["suppression_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_suppressions_suppression_id_check": { + "name": "email_suppressions_suppression_id_check", + "value": "\"email_suppressions\".\"suppression_id\" ~ '^sup_'" + } + }, + "isRLSEnabled": false + }, + "public.email_templates": { + "name": "email_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'marketing'" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_templates_team_id_title_idx": { + "name": "email_templates_team_id_title_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_templates_team_id_teams_id_fk": { + "name": "email_templates_team_id_teams_id_fk", + "tableFrom": "email_templates", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_templates_template_id_unique": { + "name": "email_templates_template_id_unique", + "nullsNotDistinct": false, + "columns": ["template_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_templates_template_id_check": { + "name": "email_templates_template_id_check", + "value": "\"email_templates\".\"template_id\" ~ '^tpl_'" + }, + "email_templates_purpose_check": { + "name": "email_templates_purpose_check", + "value": "\"email_templates\".\"purpose\" in ('marketing', 'transactional')" + } + }, + "isRLSEnabled": false + }, + "public.esp_config_team_grants": { + "name": "esp_config_team_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "drain_until": { + "name": "drain_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "daily_limit": { + "name": "daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esp_config_team_grants_non_revoked_team_idx": { + "name": "esp_config_team_grants_non_revoked_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esp_config_team_grants\".\"status\" <> 'revoked'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_config_team_grants_team_organization_fk": { + "name": "esp_config_team_grants_team_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "teams", + "columnsFrom": ["team_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_config_team_grants_esp_organization_fk": { + "name": "esp_config_team_grants_esp_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_config_team_grants_grant_id_unique": { + "name": "esp_config_team_grants_grant_id_unique", + "nullsNotDistinct": false, + "columns": ["grant_id"] + }, + "esp_config_team_grants_id_organization_id_unique": { + "name": "esp_config_team_grants_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + }, + "esp_config_team_grants_id_team_esp_unique": { + "name": "esp_config_team_grants_id_team_esp_unique", + "nullsNotDistinct": false, + "columns": ["id", "team_id", "esp_config_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_config_team_grants_public_id_check": { + "name": "esp_config_team_grants_public_id_check", + "value": "\"esp_config_team_grants\".\"grant_id\" ~ '^egr_'" + }, + "esp_config_team_grants_status_check": { + "name": "esp_config_team_grants_status_check", + "value": "\"esp_config_team_grants\".\"status\" IN ('active', 'draining', 'suspended', 'revoked')" + }, + "esp_config_team_grants_limit_check": { + "name": "esp_config_team_grants_limit_check", + "value": "(\"esp_config_team_grants\".\"daily_limit\" IS NULL OR \"esp_config_team_grants\".\"daily_limit\" >= 0)\n AND (\"esp_config_team_grants\".\"monthly_limit\" IS NULL OR \"esp_config_team_grants\".\"monthly_limit\" >= 0)" + }, + "esp_config_team_grants_created_by_type_check": { + "name": "esp_config_team_grants_created_by_type_check", + "value": "\"esp_config_team_grants\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.esp_configs": { + "name": "esp_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "esp_id": { + "name": "esp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'smtp'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 587 + }, + "secure": { + "name": "secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drain_until": { + "name": "drain_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "esp_configs_organization_id_idx": { + "name": "esp_configs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_configs_team_id_idx": { + "name": "esp_configs_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_configs_organization_id_organizations_id_fk": { + "name": "esp_configs_organization_id_organizations_id_fk", + "tableFrom": "esp_configs", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_configs_team_id_teams_id_fk": { + "name": "esp_configs_team_id_teams_id_fk", + "tableFrom": "esp_configs", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_configs_esp_id_unique": { + "name": "esp_configs_esp_id_unique", + "nullsNotDistinct": false, + "columns": ["esp_id"] + }, + "esp_configs_id_organization_id_unique": { + "name": "esp_configs_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + }, + "esp_configs_id_team_id_unique": { + "name": "esp_configs_id_team_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "team_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_configs_esp_id_check": { + "name": "esp_configs_esp_id_check", + "value": "\"esp_configs\".\"esp_id\" ~ '^esp_'" + }, + "esp_configs_owner_check": { + "name": "esp_configs_owner_check", + "value": "(\"esp_configs\".\"owner_scope\" = 'organization' AND \"esp_configs\".\"organization_id\" IS NOT NULL AND \"esp_configs\".\"team_id\" IS NULL)\n OR (\"esp_configs\".\"owner_scope\" = 'team' AND \"esp_configs\".\"organization_id\" IS NULL AND \"esp_configs\".\"team_id\" IS NOT NULL)" + }, + "esp_configs_status_check": { + "name": "esp_configs_status_check", + "value": "\"esp_configs\".\"status\" IN ('draft', 'active', 'suspended', 'draining', 'retired')" + } + }, + "isRLSEnabled": false + }, + "public.esp_feedback_connections": { + "name": "esp_feedback_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_credentials": { + "name": "encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_encrypted_credentials": { + "name": "previous_encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_credential_expires_at": { + "name": "previous_credential_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_topic_arn": { + "name": "expected_topic_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "esp_feedback_connections_team_id_idx": { + "name": "esp_feedback_connections_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_feedback_connections_esp_config_active_idx": { + "name": "esp_feedback_connections_esp_config_active_idx", + "columns": [ + { + "expression": "esp_config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esp_feedback_connections\".\"esp_config_id\" is not null and \"esp_feedback_connections\".\"status\" not in ('retiring', 'disabled')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_feedback_connections_organization_id_organizations_id_fk": { + "name": "esp_feedback_connections_organization_id_organizations_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_team_id_teams_id_fk": { + "name": "esp_feedback_connections_team_id_teams_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_esp_config_id_esp_configs_id_fk": { + "name": "esp_feedback_connections_esp_config_id_esp_configs_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_feedback_connections_connection_id_unique": { + "name": "esp_feedback_connections_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_feedback_connections_connection_id_check": { + "name": "esp_feedback_connections_connection_id_check", + "value": "\"esp_feedback_connections\".\"connection_id\" ~ '^whc_'" + }, + "esp_feedback_connections_owner_check": { + "name": "esp_feedback_connections_owner_check", + "value": "(\n \"esp_feedback_connections\".\"owner_scope\" = 'organization'\n AND \"esp_feedback_connections\".\"organization_id\" IS NOT NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NULL\n ) OR (\n \"esp_feedback_connections\".\"owner_scope\" = 'team'\n AND \"esp_feedback_connections\".\"organization_id\" IS NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.esp_webhook_receipts": { + "name": "esp_webhook_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_sha256": { + "name": "body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "safe_headers": { + "name": "safe_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "esp_webhook_receipts_status_next_attempt_idx": { + "name": "esp_webhook_receipts_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_webhook_receipts_connection_id_provider_request_id_idx": { + "name": "esp_webhook_receipts_connection_id_provider_request_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk": { + "name": "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "esp_webhook_receipts_team_id_teams_id_fk": { + "name": "esp_webhook_receipts_team_id_teams_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_webhook_receipts_receipt_id_unique": { + "name": "esp_webhook_receipts_receipt_id_unique", + "nullsNotDistinct": false, + "columns": ["receipt_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_webhook_receipts_receipt_id_check": { + "name": "esp_webhook_receipts_receipt_id_check", + "value": "\"esp_webhook_receipts\".\"receipt_id\" ~ '^whr_'" + } + }, + "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 + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_dispatch_outbox": { + "name": "mail_dispatch_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "queue_name": { + "name": "queue_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "publish_attempts": { + "name": "publish_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mail_dispatch_outbox_due_idx": { + "name": "mail_dispatch_outbox_due_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk": { + "name": "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "mail_dispatch_outbox", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail_dispatch_outbox_dispatch_id_unique": { + "name": "mail_dispatch_outbox_dispatch_id_unique", + "nullsNotDistinct": false, + "columns": ["dispatch_id"] + }, + "mail_dispatch_outbox_outbound_message_id_unique": { + "name": "mail_dispatch_outbox_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": ["outbound_message_id"] + } + }, + "policies": {}, + "checkConstraints": { + "mail_dispatch_outbox_dispatch_id_check": { + "name": "mail_dispatch_outbox_dispatch_id_check", + "value": "\"mail_dispatch_outbox\".\"dispatch_id\" ~ '^mdj_'" + }, + "mail_dispatch_outbox_state_check": { + "name": "mail_dispatch_outbox_state_check", + "value": "\"mail_dispatch_outbox\".\"state\" IN ('pending', 'publishing', 'published', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_lit_id": { + "name": "media_lit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_team_id_media_lit_id_idx": { + "name": "media_team_id_media_lit_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_lit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_team_id_created_at_idx": { + "name": "media_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_team_id_teams_id_fk": { + "name": "media_team_id_teams_id_fk", + "tableFrom": "media", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_media_id_unique": { + "name": "media_media_id_unique", + "nullsNotDistinct": false, + "columns": ["media_id"] + } + }, + "policies": {}, + "checkConstraints": { + "media_media_id_check": { + "name": "media_media_id_check", + "value": "\"media\".\"media_id\" ~ '^med_'" + } + }, + "isRLSEnabled": false + }, + "public.media_references": { + "name": "media_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_internal_id": { + "name": "resource_internal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_public_id": { + "name": "resource_public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_resource_internal_id": { + "name": "parent_resource_internal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_resource_public_id": { + "name": "parent_resource_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_references_resource_idx": { + "name": "media_references_resource_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_references_media_id_idx": { + "name": "media_references_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_references_resource_media_idx": { + "name": "media_references_resource_media_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_references_team_id_teams_id_fk": { + "name": "media_references_team_id_teams_id_fk", + "tableFrom": "media_references", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_references_media_id_media_id_fk": { + "name": "media_references_media_id_media_id_fk", + "tableFrom": "media_references", + "tableTo": "media", + "columnsFrom": ["media_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": 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": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "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": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_access_token_client_id_idx": { + "name": "auth_oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_session_id_idx": { + "name": "auth_oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_user_id_idx": { + "name": "auth_oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_authorization_code_id_idx": { + "name": "auth_oauth_access_token_authorization_code_id_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_refresh_id_idx": { + "name": "auth_oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "client_discovery_id": { + "name": "client_discovery_id", + "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 + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "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 + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "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 + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_client_user_id_idx": { + "name": "auth_oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_client_resource_client_id_idx": { + "name": "auth_oauth_client_resource_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_client_resource_resource_id_idx": { + "name": "auth_oauth_client_resource_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_client_resource_client_id_resource_id_idx": { + "name": "auth_oauth_client_resource_client_id_resource_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "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 + } + }, + "indexes": { + "auth_oauth_consent_client_id_idx": { + "name": "auth_oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_consent_user_id_idx": { + "name": "auth_oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": false + }, + "public.oauth_post_login_team_selections": { + "name": "oauth_post_login_team_selections", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_post_login_team_selections_session_id_session_id_fk": { + "name": "oauth_post_login_team_selections_session_id_session_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_post_login_team_selections_team_id_teams_id_fk": { + "name": "oauth_post_login_team_selections_team_id_teams_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "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": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_oauth_refresh_token_client_id_idx": { + "name": "auth_oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_authorization_code_id_idx": { + "name": "auth_oauth_refresh_token_authorization_code_id_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_session_id_idx": { + "name": "auth_oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_user_id_idx": { + "name": "auth_oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ongoing_sequences": { + "name": "ongoing_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "next_email_scheduled_time": { + "name": "next_email_scheduled_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_email_ids": { + "name": "sent_email_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "ongoing_sequences_sequence_id_contact_id_idx": { + "name": "ongoing_sequences_sequence_id_contact_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ongoing_sequences_next_email_scheduled_time_idx": { + "name": "ongoing_sequences_next_email_scheduled_time_idx", + "columns": [ + { + "expression": "next_email_scheduled_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ongoing_sequences_team_id_teams_id_fk": { + "name": "ongoing_sequences_team_id_teams_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ongoing_sequences_sequence_id_sequences_id_fk": { + "name": "ongoing_sequences_sequence_id_sequences_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ongoing_sequences_contact_id_contacts_id_fk": { + "name": "ongoing_sequences_contact_id_contacts_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_api_keys": { + "name": "organization_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_api_key_id": { + "name": "organization_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_api_keys_organization_id_idx": { + "name": "organization_api_keys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_api_keys_organization_id_organizations_id_fk": { + "name": "organization_api_keys_organization_id_organizations_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_api_keys_created_by_user_id_user_id_fk": { + "name": "organization_api_keys_created_by_user_id_user_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_api_keys_organization_api_key_id_unique": { + "name": "organization_api_keys_organization_api_key_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_api_key_id"] + }, + "organization_api_keys_key_hash_unique": { + "name": "organization_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_api_keys_public_id_check": { + "name": "organization_api_keys_public_id_check", + "value": "\"organization_api_keys\".\"organization_api_key_id\" ~ '^oak_'" + } + }, + "isRLSEnabled": false + }, + "public.organization_audit_events": { + "name": "organization_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_audit_events_organization_id_created_at_idx": { + "name": "organization_audit_events_organization_id_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_audit_events_team_id_created_at_idx": { + "name": "organization_audit_events_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_audit_events_organization_id_organizations_id_fk": { + "name": "organization_audit_events_organization_id_organizations_id_fk", + "tableFrom": "organization_audit_events", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_delivery_policies": { + "name": "organization_delivery_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_esp_config_id": { + "name": "default_esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auto_grant_default_esp": { + "name": "auto_grant_default_esp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_daily_limit": { + "name": "default_daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_monthly_limit": { + "name": "default_monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aggregate_daily_limit": { + "name": "aggregate_daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aggregate_monthly_limit": { + "name": "aggregate_monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "team_esp_enabled_by_default": { + "name": "team_esp_enabled_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_delivery_policies_organization_id_organizations_id_fk": { + "name": "organization_delivery_policies_organization_id_organizations_id_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_delivery_policies_default_esp_fk": { + "name": "organization_delivery_policies_default_esp_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "esp_configs", + "columnsFrom": ["default_esp_config_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_delivery_policies_organization_id_unique": { + "name": "organization_delivery_policies_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_delivery_policies_limit_check": { + "name": "organization_delivery_policies_limit_check", + "value": "(\"organization_delivery_policies\".\"default_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"default_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"default_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"default_monthly_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_monthly_limit\" >= 0)" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_quota_reservations": { + "name": "organization_esp_quota_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "reservation_id": { + "name": "reservation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day_period_start": { + "name": "day_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "month_period_start": { + "name": "month_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk": { + "name": "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_organization_id_organizations_id_fk": { + "name": "organization_esp_quota_reservations_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_grant_organization_fk": { + "name": "organization_esp_quota_reservations_grant_organization_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_esp_quota_reservations_reservation_id_unique": { + "name": "organization_esp_quota_reservations_reservation_id_unique", + "nullsNotDistinct": false, + "columns": ["reservation_id"] + }, + "organization_esp_quota_reservations_outbound_message_id_unique": { + "name": "organization_esp_quota_reservations_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": ["outbound_message_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_esp_quota_reservations_reservation_id_check": { + "name": "organization_esp_quota_reservations_reservation_id_check", + "value": "\"organization_esp_quota_reservations\".\"reservation_id\" ~ '^qrs_'" + }, + "organization_esp_quota_reservations_state_check": { + "name": "organization_esp_quota_reservations_state_check", + "value": "\"organization_esp_quota_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_usage_buckets": { + "name": "organization_esp_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "bucket_scope": { + "name": "bucket_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reserved_count": { + "name": "reserved_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_count": { + "name": "accepted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_esp_usage_buckets_grant_period_idx": { + "name": "organization_esp_usage_buckets_grant_period_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_esp_usage_buckets_organization_period_idx": { + "name": "organization_esp_usage_buckets_organization_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_esp_usage_buckets_organization_id_organizations_id_fk": { + "name": "organization_esp_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_organization_fk": { + "name": "organization_esp_usage_buckets_grant_organization_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_esp_usage_buckets_scope_check": { + "name": "organization_esp_usage_buckets_scope_check", + "value": "(\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'grant' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL\n ) OR (\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NULL\n )" + }, + "organization_esp_usage_buckets_period_check": { + "name": "organization_esp_usage_buckets_period_check", + "value": "\"organization_esp_usage_buckets\".\"period_type\" IN ('day', 'month')" + }, + "organization_esp_usage_buckets_count_check": { + "name": "organization_esp_usage_buckets_count_check", + "value": "\"organization_esp_usage_buckets\".\"reserved_count\" >= 0 AND \"organization_esp_usage_buckets\".\"accepted_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_members_organization_id_user_id_idx": { + "name": "organization_members_organization_id_user_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_user_id_fk": { + "name": "organization_members_user_id_user_id_fk", + "tableFrom": "organization_members", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_members_role_check": { + "name": "organization_members_role_check", + "value": "\"organization_members\".\"role\" IN ('owner', 'admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.organization_plan_states": { + "name": "organization_plan_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "active_subscription_id": { + "name": "active_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "teams_limit_override": { + "name": "teams_limit_override", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contacts_limit_override": { + "name": "contacts_limit_override", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "projection_version": { + "name": "projection_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_paid_activated_at": { + "name": "first_paid_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ramp_stage": { + "name": "ramp_stage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ramp_clean_stage_days": { + "name": "ramp_clean_stage_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ramp_evaluated_at": { + "name": "ramp_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_plan_states_organization_id_organizations_id_fk": { + "name": "organization_plan_states_organization_id_organizations_id_fk", + "tableFrom": "organization_plan_states", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_plan_states_active_subscription_id_organization_subscriptions_id_fk": { + "name": "organization_plan_states_active_subscription_id_organization_subscriptions_id_fk", + "tableFrom": "organization_plan_states", + "tableTo": "organization_subscriptions", + "columnsFrom": ["active_subscription_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_plan_states_organization_id_unique": { + "name": "organization_plan_states_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_plan_states_plan_check": { + "name": "organization_plan_states_plan_check", + "value": "\"organization_plan_states\".\"plan\" IN ('free', 'pro', 'business')" + }, + "organization_plan_states_teams_override_check": { + "name": "organization_plan_states_teams_override_check", + "value": "\"organization_plan_states\".\"teams_limit_override\" IS NULL OR \"organization_plan_states\".\"teams_limit_override\" > 0" + }, + "organization_plan_states_contacts_override_check": { + "name": "organization_plan_states_contacts_override_check", + "value": "\"organization_plan_states\".\"contacts_limit_override\" IS NULL OR \"organization_plan_states\".\"contacts_limit_override\" > 0" + }, + "organization_plan_states_ramp_stage_check": { + "name": "organization_plan_states_ramp_stage_check", + "value": "\"organization_plan_states\".\"ramp_stage\" BETWEEN 0 AND 3" + }, + "organization_plan_states_ramp_clean_days_check": { + "name": "organization_plan_states_ramp_clean_days_check", + "value": "\"organization_plan_states\".\"ramp_clean_stage_days\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.organization_subscriptions": { + "name": "organization_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "billing_manager_user_id": { + "name": "billing_manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "catalog_key": { + "name": "catalog_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_period_starts_at": { + "name": "current_period_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_ends_at": { + "name": "current_period_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_through_at": { + "name": "paid_through_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_at": { + "name": "past_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_ends_at": { + "name": "grace_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_entitlement_source": { + "name": "is_entitlement_source", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_provider_event_at": { + "name": "last_provider_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reconciled_at": { + "name": "last_reconciled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_subscriptions_provider_subscription_uidx": { + "name": "organization_subscriptions_provider_subscription_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_subscriptions_organization_source_uidx": { + "name": "organization_subscriptions_organization_source_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_subscriptions\".\"is_entitlement_source\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_subscriptions_organization_id_organizations_id_fk": { + "name": "organization_subscriptions_organization_id_organizations_id_fk", + "tableFrom": "organization_subscriptions", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_subscriptions_billing_customer_id_billing_provider_customers_id_fk": { + "name": "organization_subscriptions_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "organization_subscriptions", + "tableTo": "billing_provider_customers", + "columnsFrom": ["billing_customer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_subscriptions_billing_manager_user_id_user_id_fk": { + "name": "organization_subscriptions_billing_manager_user_id_user_id_fk", + "tableFrom": "organization_subscriptions", + "tableTo": "user", + "columnsFrom": ["billing_manager_user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_subscriptions_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "organization_subscriptions_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "organization_subscriptions", + "tableTo": "billing_price_entries", + "columnsFrom": ["billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_subscriptions_status_check": { + "name": "organization_subscriptions_status_check", + "value": "\"organization_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" + }, + "organization_subscriptions_plan_check": { + "name": "organization_subscriptions_plan_check", + "value": "\"organization_subscriptions\".\"plan\" IN ('pro', 'business')" + }, + "organization_subscriptions_interval_check": { + "name": "organization_subscriptions_interval_check", + "value": "\"organization_subscriptions\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_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, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_organization_id_unique": { + "name": "organizations_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organizations_organization_id_check": { + "name": "organizations_organization_id_check", + "value": "\"organizations\".\"organization_id\" ~ '^org_'" + }, + "organizations_status_check": { + "name": "organizations_status_check", + "value": "\"organizations\".\"status\" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.outbound_messages": { + "name": "outbound_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "feedback_connection_id": { + "name": "feedback_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submission_key": { + "name": "submission_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign_delivery_id": { + "name": "campaign_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transactional_email_id": { + "name": "transactional_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rfc_message_id": { + "name": "rfc_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "feedback_status": { + "name": "feedback_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bounced_at": { + "name": "bounced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "complained_at": { + "name": "complained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "outbound_messages_team_id_created_at_idx": { + "name": "outbound_messages_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_connection_provider_msg_idx": { + "name": "outbound_messages_connection_provider_msg_idx", + "columns": [ + { + "expression": "feedback_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_team_id_recipient_created_at_idx": { + "name": "outbound_messages_team_id_recipient_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbound_messages_team_id_teams_id_fk": { + "name": "outbound_messages_team_id_teams_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbound_messages_esp_config_id_esp_configs_id_fk": { + "name": "outbound_messages_esp_config_id_esp_configs_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk": { + "name": "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["feedback_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_campaign_delivery_id_email_deliveries_id_fk": { + "name": "outbound_messages_campaign_delivery_id_email_deliveries_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "email_deliveries", + "columnsFrom": ["campaign_delivery_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_transactional_email_id_transactional_emails_id_fk": { + "name": "outbound_messages_transactional_email_id_transactional_emails_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "transactional_emails", + "columnsFrom": ["transactional_email_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "outbound_messages_message_id_unique": { + "name": "outbound_messages_message_id_unique", + "nullsNotDistinct": false, + "columns": ["message_id"] + }, + "outbound_messages_submission_key_unique": { + "name": "outbound_messages_submission_key_unique", + "nullsNotDistinct": false, + "columns": ["submission_key"] + } + }, + "policies": {}, + "checkConstraints": { + "outbound_messages_message_id_check": { + "name": "outbound_messages_message_id_check", + "value": "\"outbound_messages\".\"message_id\" ~ '^msg_'" + }, + "outbound_messages_delivery_pin_check": { + "name": "outbound_messages_delivery_pin_check", + "value": "(\n \"outbound_messages\".\"delivery_source_type\" = 'team'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" = 'organization'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NOT NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" IN ('team', 'organization')\n AND \"outbound_messages\".\"esp_config_id\" IS NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n AND \"outbound_messages\".\"delivery_status\" <> 'queued'\n )" + } + }, + "isRLSEnabled": false + }, + "public.plan_send_reservations": { + "name": "plan_send_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket_id": { + "name": "bucket_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_send_reservations_outbound_uidx": { + "name": "plan_send_reservations_outbound_uidx", + "columns": [ + { + "expression": "outbound_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plan_send_reservations_expiry_idx": { + "name": "plan_send_reservations_expiry_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_send_reservations_organization_id_organizations_id_fk": { + "name": "plan_send_reservations_organization_id_organizations_id_fk", + "tableFrom": "plan_send_reservations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk": { + "name": "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk", + "tableFrom": "plan_send_reservations", + "tableTo": "plan_send_usage_buckets", + "columnsFrom": ["bucket_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plan_send_reservations_amount_check": { + "name": "plan_send_reservations_amount_check", + "value": "\"plan_send_reservations\".\"amount\" > 0" + }, + "plan_send_reservations_state_check": { + "name": "plan_send_reservations_state_check", + "value": "\"plan_send_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.plan_send_usage_buckets": { + "name": "plan_send_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket_month": { + "name": "bucket_month", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "committed": { + "name": "committed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reserved": { + "name": "reserved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_send_usage_buckets_organization_month_uidx": { + "name": "plan_send_usage_buckets_organization_month_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_send_usage_buckets_organization_id_organizations_id_fk": { + "name": "plan_send_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "plan_send_usage_buckets", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plan_send_usage_buckets_count_check": { + "name": "plan_send_usage_buckets_count_check", + "value": "\"plan_send_usage_buckets\".\"committed\" >= 0 AND \"plan_send_usage_buckets\".\"reserved\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_date_in_millis": { + "name": "event_date_in_millis", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event_data": { + "name": "event_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rules_team_id_teams_id_fk": { + "name": "rules_team_id_teams_id_fk", + "tableFrom": "rules", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rules_sequence_id_sequences_id_fk": { + "name": "rules_sequence_id_sequences_id_fk", + "tableFrom": "rules", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rules_rule_id_unique": { + "name": "rules_rule_id_unique", + "nullsNotDistinct": false, + "columns": ["rule_id"] + } + }, + "policies": {}, + "checkConstraints": { + "rules_rule_id_check": { + "name": "rules_rule_id_check", + "value": "\"rules\".\"rule_id\" ~ '^rule_'" + } + }, + "isRLSEnabled": false + }, + "public.segments": { + "name": "segments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "segments_team_id_name_idx": { + "name": "segments_team_id_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "segments_team_id_teams_id_fk": { + "name": "segments_team_id_teams_id_fk", + "tableFrom": "segments", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "segments_segment_id_unique": { + "name": "segments_segment_id_unique", + "nullsNotDistinct": false, + "columns": ["segment_id"] + } + }, + "policies": {}, + "checkConstraints": { + "segments_segment_id_check": { + "name": "segments_segment_id_check", + "value": "\"segments\".\"segment_id\" ~ '^seg_'" + } + }, + "isRLSEnabled": false + }, + "public.sending_domains": { + "name": "sending_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token_hash": { + "name": "challenge_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_check_count": { + "name": "failed_check_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failed_at": { + "name": "first_failed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sending_domains_organization_domain_uidx": { + "name": "sending_domains_organization_domain_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sending_domains_organization_id_organizations_id_fk": { + "name": "sending_domains_organization_id_organizations_id_fk", + "tableFrom": "sending_domains", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sending_domains_domain_id_unique": { + "name": "sending_domains_domain_id_unique", + "nullsNotDistinct": false, + "columns": ["domain_id"] + } + }, + "policies": {}, + "checkConstraints": { + "sending_domains_domain_id_check": { + "name": "sending_domains_domain_id_check", + "value": "\"sending_domains\".\"domain_id\" ~ '^domain_'" + }, + "sending_domains_status_check": { + "name": "sending_domains_status_check", + "value": "\"sending_domains\".\"status\" IN ('pending', 'verified', 'revoked', 'failed')" + }, + "sending_domains_failed_check_count_check": { + "name": "sending_domains_failed_check_count_check", + "value": "\"sending_domains\".\"failed_check_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sequence_emails": { + "name": "sequence_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delay_in_millis": { + "name": "delay_in_millis", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 86400000 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_data": { + "name": "action_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "sequence_emails_sequence_id_email_id_idx": { + "name": "sequence_emails_sequence_id_email_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_emails_sequence_id_sequences_id_fk": { + "name": "sequence_emails_sequence_id_sequences_id_fk", + "tableFrom": "sequence_emails", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sequence_emails_email_id_check": { + "name": "sequence_emails_email_id_check", + "value": "\"sequence_emails\".\"email_id\" ~ '^email_'" + } + }, + "isRLSEnabled": false + }, + "public.sequences": { + "name": "sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "delivery_source_intent": { + "name": "delivery_source_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_data": { + "name": "trigger_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_filter": { + "name": "exclude_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "emails_order": { + "name": "emails_order", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "entrants": { + "name": "entrants", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sequences_team_id_teams_id_fk": { + "name": "sequences_team_id_teams_id_fk", + "tableFrom": "sequences", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequences_outbox_id_esp_configs_id_fk": { + "name": "sequences_outbox_id_esp_configs_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_configs", + "columnsFrom": ["outbox_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sequences_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "sequences_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequences_sequence_id_unique": { + "name": "sequences_sequence_id_unique", + "nullsNotDistinct": false, + "columns": ["sequence_id"] + } + }, + "policies": {}, + "checkConstraints": { + "sequences_sequence_id_check": { + "name": "sequences_sequence_id_check", + "value": "\"sequences\".\"sequence_id\" ~ '^seq_'" + }, + "sequences_delivery_pin_check": { + "name": "sequences_delivery_pin_check", + "value": "(\n \"sequences\".\"delivery_source_type\" IS NULL\n AND \"sequences\".\"outbox_id\" IS NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'team'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'organization'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "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 + } + }, + "indexes": { + "auth_session_user_id_idx": { + "name": "auth_session_user_id_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": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mailing_address": { + "name": "mailing_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_team_id_teams_id_fk": { + "name": "settings_team_id_teams_id_fk", + "tableFrom": "settings", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_team_id_unique": { + "name": "settings_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_api_keys": { + "name": "team_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_api_key_id": { + "name": "team_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_api_keys_team_id_idx": { + "name": "team_api_keys_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_api_keys_team_id_teams_id_fk": { + "name": "team_api_keys_team_id_teams_id_fk", + "tableFrom": "team_api_keys", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_api_keys_team_api_key_id_unique": { + "name": "team_api_keys_team_api_key_id_unique", + "nullsNotDistinct": false, + "columns": ["team_api_key_id"] + }, + "team_api_keys_key_hash_unique": { + "name": "team_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "team_api_keys_public_id_check": { + "name": "team_api_keys_public_id_check", + "value": "\"team_api_keys\".\"team_api_key_id\" ~ '^tak_'" + }, + "team_api_keys_created_by_type_check": { + "name": "team_api_keys_created_by_type_check", + "value": "\"team_api_keys\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.team_delivery_settings": { + "name": "team_delivery_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_esp_enabled": { + "name": "team_esp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "default_source": { + "name": "default_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_team_esp_config_id": { + "name": "default_team_esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_delivery_settings_team_id_teams_id_fk": { + "name": "team_delivery_settings_team_id_teams_id_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_delivery_settings_default_team_esp_fk": { + "name": "team_delivery_settings_default_team_esp_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "esp_configs", + "columnsFrom": ["default_team_esp_config_id", "team_id"], + "columnsTo": ["id", "team_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_delivery_settings_team_id_unique": { + "name": "team_delivery_settings_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": { + "team_delivery_settings_default_source_check": { + "name": "team_delivery_settings_default_source_check", + "value": "\"team_delivery_settings\".\"default_source\" IS NULL OR \"team_delivery_settings\".\"default_source\" IN ('organization', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "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, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_user_id_idx": { + "name": "team_members_team_id_user_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_user_id_fk": { + "name": "team_members_user_id_user_id_fk", + "tableFrom": "team_members", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_members_role_check": { + "name": "team_members_role_check", + "value": "\"team_members\".\"role\" IN ('admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.team_sending_controls": { + "name": "team_sending_controls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'automatic'" + }, + "entered_at": { + "name": "entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evaluated_at": { + "name": "evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "minimum_hold_until": { + "name": "minimum_hold_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operator_reason": { + "name": "operator_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overridden_at": { + "name": "overridden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clean_evaluation_days": { + "name": "clean_evaluation_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_sending_controls_team_id_teams_id_fk": { + "name": "team_sending_controls_team_id_teams_id_fk", + "tableFrom": "team_sending_controls", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "team_sending_controls_operator_user_id_user_id_fk": { + "name": "team_sending_controls_operator_user_id_user_id_fk", + "tableFrom": "team_sending_controls", + "tableTo": "user", + "columnsFrom": ["operator_user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_sending_controls_team_id_unique": { + "name": "team_sending_controls_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": { + "team_sending_controls_status_check": { + "name": "team_sending_controls_status_check", + "value": "\"team_sending_controls\".\"status\" IN ('normal', 'warned', 'marketing_paused', 'all_paused')" + }, + "team_sending_controls_source_check": { + "name": "team_sending_controls_source_check", + "value": "\"team_sending_controls\".\"source\" IN ('automatic', 'operator')" + }, + "team_sending_controls_clean_days_check": { + "name": "team_sending_controls_clean_days_check", + "value": "\"team_sending_controls\".\"clean_evaluation_days\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_request_hash": { + "name": "provisioning_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_external_id_idx": { + "name": "teams_organization_id_external_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"teams\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_team_id_unique": { + "name": "teams_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + }, + "teams_id_organization_id_unique": { + "name": "teams_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "teams_team_id_check": { + "name": "teams_team_id_check", + "value": "\"teams\".\"team_id\" ~ '^team_'" + }, + "teams_status_check": { + "name": "teams_status_check", + "value": "\"teams\".\"status\" IN ('active', 'sending_suspended', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.transactional_emails": { + "name": "transactional_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "txe_id": { + "name": "txe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "track_opens": { + "name": "track_opens", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "track_clicks": { + "name": "track_clicks", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transactional_emails_team_id_idempotency_key_idx": { + "name": "transactional_emails_team_id_idempotency_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"transactional_emails\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_created_at_idx": { + "name": "transactional_emails_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_status_idx": { + "name": "transactional_emails_team_id_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_emails_team_id_teams_id_fk": { + "name": "transactional_emails_team_id_teams_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactional_emails_outbox_id_esp_configs_id_fk": { + "name": "transactional_emails_outbox_id_esp_configs_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_configs", + "columnsFrom": ["outbox_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "transactional_emails_contact_id_contacts_id_fk": { + "name": "transactional_emails_contact_id_contacts_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "transactional_emails_txe_id_unique": { + "name": "transactional_emails_txe_id_unique", + "nullsNotDistinct": false, + "columns": ["txe_id"] + } + }, + "policies": {}, + "checkConstraints": { + "transactional_emails_txe_id_check": { + "name": "transactional_emails_txe_id_check", + "value": "\"transactional_emails\".\"txe_id\" ~ '^txe_'" + }, + "transactional_emails_delivery_pin_check": { + "name": "transactional_emails_delivery_pin_check", + "value": "(\n \"transactional_emails\".\"delivery_source_type\" = 'team'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NULL\n ) OR (\n \"transactional_emails\".\"delivery_source_type\" = 'organization'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "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, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_organization_id": { + "name": "default_organization_id", + "type": "uuid", + "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": {}, + "foreignKeys": { + "user_default_organization_id_organizations_id_fk": { + "name": "user_default_organization_id_organizations_id_fk", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": ["default_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "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": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_verification_identifier_idx": { + "name": "auth_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": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 8ed5cb5..b8f2107 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787852563794, "tag": "0004_kind_the_executioner", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1788000470925, + "tag": "0005_many_shape", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index d96952e..9d3912b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,8 +7,10 @@ "main": "dist/index.js", "scripts": { "build": "tsc", + "predev": "pnpm --filter @sendlit/api-contract build", "dev": "nodemon --exec 'node --env-file=.env --import tsx' src/index.ts", "access-token": "tsx --env-file=.env scripts/token.ts", + "billing": "tsx --env-file=.env scripts/billing.ts", "start": "node dist/index.js", "db:migrate": "node --import dotenv/config dist/db/migrate.js", "db:generate": "drizzle-kit generate", @@ -32,6 +34,7 @@ "better-auth": "1.7.0-rc.4", "bullmq": "^5.34.0", "cors": "^2.8.5", + "dodopayments": "^2.48.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.2", "express": "^4.21.2", diff --git a/apps/api/scripts/billing.ts b/apps/api/scripts/billing.ts new file mode 100644 index 0000000..abfcf02 --- /dev/null +++ b/apps/api/scripts/billing.ts @@ -0,0 +1,359 @@ +import "dotenv/config"; + +import { and, eq } from "drizzle-orm"; +import { db, pool } from "../src/db/client"; +import { + billingCatalogRevisions, + billingWebhookEvents, + organizationPlanStates, + organizationSubscriptions, + organizations, + teams, +} from "../src/db/schema"; +import { readBillingConfig } from "../src/billing/catalog"; +import { + abandonCatalogRevision, + recordRequestedCatalogRevision, + verifyCatalogAgainstProvider, +} from "../src/billing/catalog-store"; +import { getBillingProvider } from "../src/billing/provider-registry"; +import { applyCanonicalBillingEvent } from "../src/billing/webhooks/processor"; +import { + applyTeamSendingControl, + releaseTeamSendingControl, +} from "../src/billing/reputation"; +import { decryptBillingValue } from "../src/billing/crypto"; +import { recordBillingMetric } from "../src/billing/metrics"; + +function usage(): never { + console.error(`Usage: + billing catalog-status + billing catalog-verify + billing catalog-abandon --reason + billing reconcile-org + billing webhook-retry + billing webhook-inspect + billing set-override --teams |none --contacts |none --reason + billing reputation-apply --operator --reason + billing reputation-release --operator --reason + billing cancel-subscription --reason +`); + process.exit(1); +} + +function arg(flag: string, argv: string[]): string | undefined { + const index = argv.indexOf(flag); + if (index === -1) return undefined; + return argv[index + 1]; +} + +async function catalogStatus() { + const config = readBillingConfig(); + const rows = await db.select().from(billingCatalogRevisions); + console.log( + JSON.stringify( + { + config: { + mode: config.deploymentMode, + revision: config.catalogRevision, + provider: config.checkoutProvider, + }, + revisions: rows, + }, + null, + 2, + ), + ); +} + +async function catalogVerify() { + const config = readBillingConfig(); + await recordRequestedCatalogRevision(config); + if (config.deploymentMode !== "cloud") { + console.log("oss mode: nothing to verify"); + return; + } + const provider = getBillingProvider(config.checkoutProvider ?? undefined); + await verifyCatalogAgainstProvider(config, provider); + console.log("catalog verified"); +} + +async function catalogAbandon( + revisionRaw: string | undefined, + reason: string | undefined, +) { + if (!revisionRaw || !reason) usage(); + const revision = Number(revisionRaw); + if (!Number.isSafeInteger(revision) || revision <= 0) usage(); + const ok = await abandonCatalogRevision(revision, reason); + if (!ok) { + console.error("revision not found"); + process.exit(1); + } + console.log("abandoned", revision); +} + +async function reconcileOrg(publicId: string | undefined) { + if (!publicId) usage(); + const [organization] = await db + .select() + .from(organizations) + .where(eq(organizations.organizationId, publicId)) + .limit(1); + if (!organization) { + console.error("organization not found"); + process.exit(1); + } + const [subscription] = await db + .select() + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.organizationId, organization.id), + eq(organizationSubscriptions.isEntitlementSource, true), + ), + ) + .limit(1); + if (!subscription) { + console.log("no entitlement-bearing subscription"); + return; + } + const provider = getBillingProvider(subscription.provider); + const snapshot = await provider.retrieveSubscription( + subscription.providerSubscriptionId, + ); + await applyCanonicalBillingEvent({ + provider: subscription.provider, + providerEventId: `operator-reconcile:${subscription.id}:${new Date().toISOString()}`, + eventType: "subscription.reconciled", + occurredAt: snapshot.occurredAt, + subscriptionId: snapshot.providerSubscriptionId, + snapshot, + rawPayload: null, + }); + console.log("reconciled", publicId); +} + +async function webhookRetry(eventId: string | undefined) { + if (!eventId) usage(); + const [event] = await db + .select() + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.providerEventId, eventId)) + .limit(1); + if (!event) { + console.error("event not found"); + process.exit(1); + } + await db + .update(billingWebhookEvents) + .set({ + status: "pending", + availableAt: new Date(), + lockedAt: null, + leaseExpiresAt: null, + }) + .where(eq(billingWebhookEvents.id, event.id)); + const { processBillingWebhookInboxEvent } = + await import("../src/billing/webhooks/processor.js"); + await processBillingWebhookInboxEvent(event.id); + console.log("retried", eventId); +} + +async function webhookInspect(eventId: string | undefined) { + if (!eventId) usage(); + const [event] = await db + .select() + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.providerEventId, eventId)) + .limit(1); + if (!event) { + console.error("event not found"); + process.exit(1); + } + let payload: unknown = null; + if (event.payloadEncrypted) { + payload = JSON.parse(decryptBillingValue(event.payloadEncrypted)); + } + console.log( + JSON.stringify( + { + id: event.id, + status: event.status, + eventType: event.eventType, + lastError: event.lastError, + payload, + }, + null, + 2, + ), + ); + recordBillingMetric("billing.operator.webhook_decrypt", { + provider_event_id: eventId, + }); +} + +async function setOverride( + publicId: string | undefined, + teamsRaw: string | undefined, + contactsRaw: string | undefined, + reason: string | undefined, +) { + if (!publicId || !reason) usage(); + const [organization] = await db + .select() + .from(organizations) + .where(eq(organizations.organizationId, publicId)) + .limit(1); + if (!organization) { + console.error("organization not found"); + process.exit(1); + } + const parse = (raw: string | undefined) => { + if (raw === undefined || raw === "none") return null; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) usage(); + return value; + }; + await db + .update(organizationPlanStates) + .set({ + teamsLimitOverride: parse(teamsRaw), + contactsLimitOverride: parse(contactsRaw), + updatedAt: new Date(), + }) + .where(eq(organizationPlanStates.organizationId, organization.id)); + recordBillingMetric("billing.operator.override", { + organization_public_id: publicId, + reason, + }); + console.log("overrides updated"); +} + +async function reputationApply( + teamPublicId: string | undefined, + status: string | undefined, + operator: string | undefined, + reason: string | undefined, +) { + if (!teamPublicId || !status || !operator || !reason) usage(); + if ( + status !== "warned" && + status !== "marketing_paused" && + status !== "all_paused" + ) { + usage(); + } + const [team] = await db + .select() + .from(teams) + .where(eq(teams.teamId, teamPublicId)) + .limit(1); + if (!team) { + console.error("team not found"); + process.exit(1); + } + const ok = await applyTeamSendingControl(team.id, status, operator, reason); + console.log(ok ? "applied" : "not applied"); +} + +async function reputationRelease( + teamPublicId: string | undefined, + operator: string | undefined, + reason: string | undefined, +) { + if (!teamPublicId || !operator || !reason) usage(); + const [team] = await db + .select() + .from(teams) + .where(eq(teams.teamId, teamPublicId)) + .limit(1); + if (!team) { + console.error("team not found"); + process.exit(1); + } + const ok = await releaseTeamSendingControl(team.id, operator, reason); + console.log(ok ? "released" : "not released"); +} + +async function cancelSubscription( + publicId: string | undefined, + reason: string | undefined, +) { + if (!publicId || !reason) usage(); + const [organization] = await db + .select() + .from(organizations) + .where(eq(organizations.organizationId, publicId)) + .limit(1); + if (!organization) { + console.error("organization not found"); + process.exit(1); + } + const [subscription] = await db + .select() + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.organizationId, organization.id), + eq(organizationSubscriptions.isEntitlementSource, true), + ), + ) + .limit(1); + if (!subscription) { + console.error("no live subscription"); + process.exit(1); + } + const provider = getBillingProvider(subscription.provider); + await provider.cancelSubscription( + subscription.providerSubscriptionId, + `operator-cancel:${subscription.id}`, + ); + recordBillingMetric("billing.operator.cancel_subscription", { + organization_public_id: publicId, + reason, + }); + console.log( + "provider cancellation requested; wait for webhook/reconciliation", + ); +} + +async function main() { + const [command, ...rest] = process.argv.slice(2); + try { + if (command === "catalog-status") await catalogStatus(); + else if (command === "catalog-verify") await catalogVerify(); + else if (command === "catalog-abandon") + await catalogAbandon(rest[0], arg("--reason", rest)); + else if (command === "reconcile-org") await reconcileOrg(rest[0]); + else if (command === "webhook-retry") await webhookRetry(rest[0]); + else if (command === "webhook-inspect") await webhookInspect(rest[0]); + else if (command === "set-override") + await setOverride( + rest[0], + arg("--teams", rest), + arg("--contacts", rest), + arg("--reason", rest), + ); + else if (command === "reputation-apply") + await reputationApply( + rest[0], + rest[1], + arg("--operator", rest), + arg("--reason", rest), + ); + else if (command === "reputation-release") + await reputationRelease( + rest[0], + arg("--operator", rest), + arg("--reason", rest), + ); + else if (command === "cancel-subscription") + await cancelSubscription(rest[0], arg("--reason", rest)); + else usage(); + } finally { + await pool.end(); + } +} + +void main(); diff --git a/apps/api/src/automation/process-ongoing-sequence.ts b/apps/api/src/automation/process-ongoing-sequence.ts index 820aca5..0b5e7ba 100644 --- a/apps/api/src/automation/process-ongoing-sequence.ts +++ b/apps/api/src/automation/process-ongoing-sequence.ts @@ -39,10 +39,12 @@ import { markOutboundAccepted } from "../delivery-feedback/outbound-queries"; import { normalizeEmail } from "../utils/email"; import { validateTemplateContent } from "../templates/validation"; import { resolvePinnedDeliverySource } from "../delivery/queries"; +import { getOrganizationEntitlements } from "../billing/entitlements"; +import { commitQuotaForOutbound } from "../delivery/quota"; import { - commitQuotaForOutbound, - reserveOrganizationQuotaForOutbound, -} from "../delivery/quota"; + commitSendReservation, + releaseSendReservation, +} from "../billing/entitlements"; type OngoingSequenceRow = typeof ongoingSequences.$inferSelect; type SequenceEmailRow = typeof sequenceEmails.$inferSelect; @@ -211,6 +213,7 @@ async function attemptMailSending({ type: sequence.deliverySourceType as "organization" | "team", espConfigId: sequence.outboxId, espGrantId: sequence.espGrantId, + purpose: "marketing", }); const from = getEmailFrom({ name: pin.fromName, @@ -276,6 +279,10 @@ async function attemptMailSending({ const renderedHtml = await renderEmailContent({ content: emailContentWithPixel, variables: templatePayload, + brandingText: (await getOrganizationEntitlements(team.organizationId)) + .marketingBranding + ? "Sent with SendLit" + : undefined, }); const contentWithTrackedLinks = transformLinksForClickTracking( @@ -293,6 +300,7 @@ async function attemptMailSending({ { sequence_id: sequence.sequenceId, email_id: email.emailId }, ); + let outboundForReservation: { id: string } | null = null; try { // Outbound ledger row must exist before transport submission — see // docs/bounces-and-complaints.md#1-outbound-message-ledger. @@ -306,12 +314,17 @@ async function attemptMailSending({ submissionKey: `campaign:${ongoingSequence.id}:${email.id}`, recipientEmail: to, normalizedRecipient: normalizeEmail(to), + organizationQuotaGrantId: + pin.type === "organization" ? pin.espGrantId : null, }); - if (pin.type === "organization") { - await reserveOrganizationQuotaForOutbound({ - outboundMessageId: outbound.id, - grantId: pin.espGrantId!, - }); + outboundForReservation = outbound; + // A transport may have succeeded immediately before the worker + // crashed. The durable outbound ledger is then the source of truth: + // finish the workflow action without submitting the same message a + // second time. + if (outbound.deliveryStatus === "accepted") { + await applyEmailAction({ team, contact, sequence, email }); + return; } const result = await sendMail({ from, @@ -337,10 +350,16 @@ async function attemptMailSending({ campaignDeliveryId: delivery.id, }); await commitQuotaForOutbound(outbound.id); + await commitSendReservation(outbound.id); await applyEmailAction({ team, contact, sequence, email }); } catch (err: any) { const retryCount = ongoingSequence.retryCount + 1; if (retryCount >= sequenceBounceLimit) { + if (outboundForReservation) { + await releaseSendReservation(outboundForReservation.id).catch( + () => undefined, + ); + } await db .update(sequences) .set({ @@ -359,6 +378,9 @@ async function attemptMailSending({ .where(eq(sequences.id, sequence.id)); await deleteOngoingSequence(ongoingSequence.id); } else { + // Keep the reservation through a transient retry. It expires and + // is reopened atomically by reserveSend, preventing a retry from + // bypassing the monthly quota. await db .update(ongoingSequences) .set({ retryCount }) diff --git a/apps/api/src/automation/queries.ts b/apps/api/src/automation/queries.ts index b61b2d8..a8c7553 100644 --- a/apps/api/src/automation/queries.ts +++ b/apps/api/src/automation/queries.ts @@ -1,6 +1,13 @@ import { and, count, eq, isNull, lt, or, sql } from "drizzle-orm"; import { db } from "../db/client"; -import { contacts, ongoingSequences, rules, sequences } from "../db/schema"; +import { + contacts, + ongoingSequences, + rules, + sequences, + teams, +} from "../db/schema"; +import { assertMarketingAllowedForContactUsage } from "../billing/entitlements"; import { EventType, sequenceBounceLimit } from "../config/constants"; import { buildContactFilterCondition, @@ -105,6 +112,19 @@ export async function enrollContactsInOngoingSequence({ contactIds: string[]; }) { if (contactIds.length === 0) return; + const [team] = await db + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (team) { + await db.transaction(async (tx) => { + await assertMarketingAllowedForContactUsage( + tx, + team.organizationId, + ); + }); + } const now = Date.now(); await db .insert(ongoingSequences) diff --git a/apps/api/src/billing/alerts.test.ts b/apps/api/src/billing/alerts.test.ts new file mode 100644 index 0000000..6ced7f4 --- /dev/null +++ b/apps/api/src/billing/alerts.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); + +import { db } from "../db/client"; +import { billingCatalogRevisions, billingWebhookEvents } from "../db/schema"; +import { truncateAll, type TestDb } from "../test/db"; +import { + collectBillingSloAlerts, + recordBillingHourlySuccess, + recordWebhookSignatureFailure, + resetBillingAlertsForTests, +} from "./alerts"; + +const tdb = db as unknown as TestDb; + +beforeEach(async () => { + resetBillingAlertsForTests(); + recordBillingHourlySuccess(); + await truncateAll(tdb); +}); + +describe("billing SLO alerts", () => { + it("pages when a webhook is quarantined", async () => { + await tdb.insert(billingWebhookEvents).values({ + provider: "fake", + providerEventId: "evt_quarantine", + eventType: "subscription.updated", + occurredAt: new Date(), + status: "quarantined", + }); + const alerts = await collectBillingSloAlerts(); + expect(alerts.map((alert) => alert.code)).toContain( + "webhook_quarantined", + ); + }); + + it("pages when inbox lag exceeds five minutes", async () => { + await tdb.insert(billingWebhookEvents).values({ + provider: "fake", + providerEventId: "evt_lag", + eventType: "subscription.updated", + occurredAt: new Date("2026-08-01T00:00:00.000Z"), + receivedAt: new Date("2026-08-01T00:00:00.000Z"), + status: "pending", + }); + const alerts = await collectBillingSloAlerts( + new Date("2026-08-01T00:10:00.000Z"), + ); + expect(alerts.map((alert) => alert.code)).toContain( + "webhook_inbox_lag", + ); + }); + + it("pages when the catalog is invalid", async () => { + await tdb.insert(billingCatalogRevisions).values({ + revision: 9, + checkoutProvider: "fake", + status: "invalid", + }); + const alerts = await collectBillingSloAlerts(); + expect(alerts.map((alert) => alert.code)).toContain("catalog_invalid"); + }); + + it("counts signature failures toward a spike", () => { + const now = new Date("2026-08-01T00:00:00.000Z"); + let count = 0; + for (let i = 0; i < 10; i += 1) { + count = recordWebhookSignatureFailure(now); + } + expect(count).toBe(10); + }); +}); diff --git a/apps/api/src/billing/alerts.ts b/apps/api/src/billing/alerts.ts new file mode 100644 index 0000000..1495034 --- /dev/null +++ b/apps/api/src/billing/alerts.ts @@ -0,0 +1,288 @@ +import { and, eq, inArray, isNull, lt, min, or, sql } from "drizzle-orm"; +import { createTransport } from "nodemailer"; +import { db } from "../db/client"; +import { + billingCatalogRevisions, + billingCheckoutAttempts, + billingProviderCustomers, + billingWebhookEvents, + organizationSubscriptions, +} from "../db/schema"; +import logger from "../services/log"; +import { captureError, captureEvent } from "../observability/posthog"; +import { recordBillingMetric } from "./metrics"; + +export type BillingAlertCode = + | "webhook_quarantined" + | "webhook_inbox_lag" + | "webhook_signature_spike" + | "checkout_creating_stuck" + | "customer_creating_stuck" + | "subscription_unreconciled" + | "catalog_invalid" + | "hourly_job_missed"; + +export type BillingAlert = { + code: BillingAlertCode; + message: string; + details: Record; +}; + +const PAGE_COOLDOWN_MS = 30 * 60 * 1000; +const INBOX_LAG_MS = 5 * 60 * 1000; +const STUCK_CREATING_MS = 15 * 60 * 1000; +const UNRECONCILED_MS = 6 * 60 * 60 * 1000; +const HOURLY_MISS_MS = 2 * 60 * 60 * 1000; +const SIGNATURE_WINDOW_MS = 5 * 60 * 1000; +const SIGNATURE_SPIKE = 10; + +const lastPagedAt = new Map(); +const signatureFailures: number[] = []; +let lastHourlySuccessAt = Date.now(); + +export function resetBillingAlertsForTests(): void { + lastPagedAt.clear(); + signatureFailures.length = 0; + lastHourlySuccessAt = Date.now(); +} + +export function recordBillingHourlySuccess(now = new Date()): void { + lastHourlySuccessAt = now.getTime(); +} + +export function recordWebhookSignatureFailure(now = new Date()): number { + signatureFailures.push(now.getTime()); + const cutoff = now.getTime() - SIGNATURE_WINDOW_MS; + while ( + signatureFailures[0] !== undefined && + signatureFailures[0] < cutoff + ) { + signatureFailures.shift(); + } + return signatureFailures.length; +} + +function adminRecipients(): string[] { + const raw = + process.env.BILLING_ALERT_EMAIL || process.env.SUPER_ADMIN_EMAIL || ""; + return raw + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +async function emailAdmins(subject: string, text: string): Promise { + const to = adminRecipients(); + if (to.length === 0) return; + if (!process.env.EMAIL_HOST || !process.env.EMAIL_FROM) { + logger.warn( + { subject }, + "billing page email skipped: SMTP is not configured", + ); + return; + } + if (process.env.NODE_ENV !== "production") { + logger.info({ to, subject, text }, "[Dev] billing page"); + return; + } + const transporter = createTransport({ + host: process.env.EMAIL_HOST, + port: Number(process.env.EMAIL_PORT) || 587, + auth: process.env.EMAIL_USER + ? { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS || "", + } + : undefined, + }); + await transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: to.join(", "), + subject, + text, + }); +} + +export async function pageBillingAlert( + alert: BillingAlert, + now = new Date(), +): Promise { + const last = lastPagedAt.get(alert.code) ?? 0; + if ( + now.getTime() - last < PAGE_COOLDOWN_MS && + alert.code !== "webhook_quarantined" + ) { + return; + } + lastPagedAt.set(alert.code, now.getTime()); + logger.error( + { billing_alert: alert.code, ...alert.details }, + alert.message, + ); + recordBillingMetric("billing.page", { code: alert.code, ...alert.details }); + captureEvent({ + event: "billing.page", + source: "billing.slo", + properties: { alert_code: alert.code, ...alert.details }, + }); + captureError({ + error: new Error(alert.message), + source: "billing.slo", + severity: "critical", + context: { alert_code: alert.code, error_code: alert.code }, + }); + await emailAdmins( + `[SendLit billing] ${alert.code}`, + `${alert.message}\n${JSON.stringify(alert.details)}`, + ); +} + +export async function collectBillingSloAlerts( + now = new Date(), +): Promise { + const alerts: BillingAlert[] = []; + const [oldestPending] = await db + .select({ receivedAt: min(billingWebhookEvents.receivedAt) }) + .from(billingWebhookEvents) + .where( + inArray(billingWebhookEvents.status, [ + "pending", + "failed", + "processing", + ]), + ); + if (oldestPending?.receivedAt) { + const ageMs = now.getTime() - oldestPending.receivedAt.getTime(); + if (ageMs >= INBOX_LAG_MS) { + alerts.push({ + code: "webhook_inbox_lag", + message: + "Billing webhook inbox has a pending event older than five minutes.", + details: { age_ms: ageMs }, + }); + } + } + + const [quarantined] = await db + .select({ value: sql`count(*)` }) + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.status, "quarantined")); + const quarantinedCount = Number(quarantined?.value ?? 0); + if (quarantinedCount > 0) { + alerts.push({ + code: "webhook_quarantined", + message: + "A billing webhook event is quarantined and needs operator review.", + details: { count: quarantinedCount }, + }); + } + + const cutoff = now.getTime() - SIGNATURE_WINDOW_MS; + const spike = signatureFailures.filter((stamp) => stamp >= cutoff).length; + if (spike >= SIGNATURE_SPIKE) { + alerts.push({ + code: "webhook_signature_spike", + message: "Billing webhook signature failures are spiking.", + details: { count: spike }, + }); + } + + const stuckSince = new Date(now.getTime() - STUCK_CREATING_MS); + const [stuckCheckout] = await db + .select({ value: sql`count(*)` }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.status, "creating"), + lt(billingCheckoutAttempts.updatedAt, stuckSince), + ), + ); + if (Number(stuckCheckout?.value ?? 0) > 0) { + alerts.push({ + code: "checkout_creating_stuck", + message: + "A billing checkout attempt has been creating for more than 15 minutes.", + details: { count: Number(stuckCheckout?.value ?? 0) }, + }); + } + const [stuckCustomer] = await db + .select({ value: sql`count(*)` }) + .from(billingProviderCustomers) + .where( + and( + eq(billingProviderCustomers.status, "creating"), + lt(billingProviderCustomers.updatedAt, stuckSince), + ), + ); + if (Number(stuckCustomer?.value ?? 0) > 0) { + alerts.push({ + code: "customer_creating_stuck", + message: + "A billing provider customer has been creating for more than 15 minutes.", + details: { count: Number(stuckCustomer?.value ?? 0) }, + }); + } + + const unreconciledSince = new Date(now.getTime() - UNRECONCILED_MS); + const [unreconciled] = await db + .select({ value: sql`count(*)` }) + .from(organizationSubscriptions) + .where( + and( + inArray(organizationSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + "cancelled", + ]), + or( + isNull(organizationSubscriptions.lastReconciledAt), + lt( + organizationSubscriptions.lastReconciledAt, + unreconciledSince, + ), + ), + ), + ); + if (Number(unreconciled?.value ?? 0) > 0) { + alerts.push({ + code: "subscription_unreconciled", + message: + "A nonterminal subscription has not reconciled in six hours.", + details: { count: Number(unreconciled?.value ?? 0) }, + }); + } + + const [invalidCatalog] = await db + .select({ value: sql`count(*)` }) + .from(billingCatalogRevisions) + .where(eq(billingCatalogRevisions.status, "invalid")); + if (Number(invalidCatalog?.value ?? 0) > 0) { + alerts.push({ + code: "catalog_invalid", + message: + "The billing catalog revision is invalid; checkout is frozen.", + details: { count: Number(invalidCatalog?.value ?? 0) }, + }); + } + + if (now.getTime() - lastHourlySuccessAt >= HOURLY_MISS_MS) { + alerts.push({ + code: "hourly_job_missed", + message: + "The hourly billing reconciliation job has missed two scheduled runs.", + details: { age_ms: now.getTime() - lastHourlySuccessAt }, + }); + } + + return alerts; +} + +export async function evaluateBillingSloAlerts( + now = new Date(), +): Promise { + const alerts = await collectBillingSloAlerts(now); + for (const alert of alerts) await pageBillingAlert(alert, now); + return alerts; +} diff --git a/apps/api/src/billing/catalog-store.test.ts b/apps/api/src/billing/catalog-store.test.ts new file mode 100644 index 0000000..6c62531 --- /dev/null +++ b/apps/api/src/billing/catalog-store.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); + +import { db } from "../db/client"; +import { truncateAll, type TestDb } from "../test/db"; +import { readBillingConfig } from "./catalog"; +import { + getActiveCatalog, + verifyCatalogAgainstProvider, +} from "./catalog-store"; +import { FakeBillingProvider } from "./providers/fake"; + +const tdb = db as unknown as TestDb; + +function cloudFakeEnv() { + process.env.SENDLIT_DEPLOYMENT_MODE = "cloud"; + process.env.BILLING_CHECKOUT_PROVIDER = "fake"; + process.env.BILLING_ENABLED_PROVIDERS = "fake"; + process.env.BILLING_CATALOG_REVISION = "1"; + process.env.BILLING_CURRENCY = "USD"; + process.env.BILLING_PRO_MONTH_AMOUNT_MINOR = "4900"; + process.env.BILLING_PRO_YEAR_AMOUNT_MINOR = "49000"; + process.env.BILLING_BUSINESS_MONTH_AMOUNT_MINOR = "19900"; + process.env.BILLING_BUSINESS_YEAR_AMOUNT_MINOR = "199000"; + process.env.FAKE_PRO_MONTH_PRODUCT_ID = "pdt_pro_month"; + process.env.FAKE_PRO_YEAR_PRODUCT_ID = "pdt_pro_year"; + process.env.FAKE_BUSINESS_MONTH_PRODUCT_ID = "pdt_business_month"; + process.env.FAKE_BUSINESS_YEAR_PRODUCT_ID = "pdt_business_year"; + process.env.BILLING_TRIAL_EMAIL_HMAC_KEY = "trial-hmac-secret"; +} + +beforeEach(async () => { + cloudFakeEnv(); + await truncateAll(tdb); +}); + +describe("catalog verification against the fake adapter", () => { + it("activates a four-offer revision when products match", async () => { + const provider = new FakeBillingProvider(); + provider.seedDefaultCatalog(); + await verifyCatalogAgainstProvider(readBillingConfig(), provider); + const active = await getActiveCatalog(readBillingConfig()); + expect(active.revision.revision).toBe(1); + expect(active.items).toHaveLength(4); + }); + + it("rejects a provider amount mismatch without activating", async () => { + const provider = new FakeBillingProvider(); + provider.seedDefaultCatalog(); + provider.seedProduct({ + provider: "fake", + providerProductId: "pdt_pro_month", + currency: "USD", + amountMinor: 9900, + interval: "month", + }); + await expect( + verifyCatalogAgainstProvider(readBillingConfig(), provider), + ).rejects.toThrow(/billing_catalog_unavailable/); + }); +}); diff --git a/apps/api/src/billing/catalog-store.ts b/apps/api/src/billing/catalog-store.ts new file mode 100644 index 0000000..d9faec7 --- /dev/null +++ b/apps/api/src/billing/catalog-store.ts @@ -0,0 +1,364 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCatalogRevisionItems, + billingCatalogRevisions, + billingPriceEntries, +} from "../db/schema"; +import type { BillingConfig, BillingOffer } from "./catalog"; +import { billingCatalogKeys } from "./catalog"; +import { recordBillingMetric } from "./metrics"; +import { pageBillingAlert } from "./alerts"; +import type { BillingProviderAdapter } from "./provider"; +import { providerErrorSummary } from "./provider"; +import logger from "../services/log"; + +export class BillingCatalogUnavailableError extends Error { + constructor(message = "billing_catalog_unavailable") { + super(message); + this.name = "BillingCatalogUnavailableError"; + } +} + +/** Record a higher requested revision as pending without calling the provider. + * Ordinary API startup must not wait on provider availability. */ +export async function recordRequestedCatalogRevision( + config: BillingConfig, +): Promise { + if (config.deploymentMode !== "cloud" || !config.catalogRevision) return; + const [existing] = await db + .select({ + id: billingCatalogRevisions.id, + status: billingCatalogRevisions.status, + }) + .from(billingCatalogRevisions) + .where(eq(billingCatalogRevisions.revision, config.catalogRevision)) + .limit(1); + if (existing) return; + await db + .insert(billingCatalogRevisions) + .values({ + revision: config.catalogRevision, + checkoutProvider: config.checkoutProvider!, + status: "pending_verification", + }) + .onConflictDoNothing({ target: billingCatalogRevisions.revision }); + recordBillingMetric("billing.catalog.revision_recorded", { + revision: config.catalogRevision, + status: "pending_verification", + }); +} + +export async function getActiveCatalog(config: BillingConfig) { + if (config.deploymentMode !== "cloud" || !config.checkoutProvider) { + throw new BillingCatalogUnavailableError(); + } + const [active] = await db + .select() + .from(billingCatalogRevisions) + .where( + and( + eq( + billingCatalogRevisions.checkoutProvider, + config.checkoutProvider, + ), + eq(billingCatalogRevisions.status, "active"), + ), + ) + .limit(1); + if (!active) throw new BillingCatalogUnavailableError(); + const items = await db + .select({ + catalogKey: billingCatalogRevisionItems.catalogKey, + price: billingPriceEntries, + }) + .from(billingCatalogRevisionItems) + .innerJoin( + billingPriceEntries, + eq( + billingPriceEntries.id, + billingCatalogRevisionItems.billingPriceEntryId, + ), + ) + .where(eq(billingCatalogRevisionItems.catalogRevisionId, active.id)); + if (items.length !== billingCatalogKeys.length) { + throw new BillingCatalogUnavailableError(); + } + return { revision: active, items }; +} + +export function checkoutIsAvailable( + config: BillingConfig, + activeRevision: number | null, +): boolean { + return ( + config.deploymentMode === "cloud" && + activeRevision !== null && + activeRevision === config.catalogRevision + ); +} + +async function verifyOffer( + provider: BillingProviderAdapter, + offer: BillingOffer, +) { + const snapshot = await provider.retrieveProduct(offer.providerProductId); + if ( + snapshot.provider !== offer.provider || + snapshot.providerProductId !== offer.providerProductId || + snapshot.currency !== offer.currency || + snapshot.amountMinor !== offer.amountMinor || + snapshot.interval !== offer.interval + ) { + throw new Error(`billing_catalog_product_mismatch:${offer.catalogKey}`); + } + return snapshot; +} + +async function markRevision( + revisionId: string, + status: "invalid" | "abandoned" | "pending_verification", + extra: Record = {}, +) { + await db + .update(billingCatalogRevisions) + .set({ + status, + updatedAt: new Date(), + ...extra, + }) + .where(eq(billingCatalogRevisions.id, revisionId)); +} + +/** Verify the requested env revision (or re-verify the active one). A mismatch + * disables checkout without changing entitlements. */ +export async function verifyCatalogAgainstProvider( + config: BillingConfig, + provider: BillingProviderAdapter, +): Promise { + if (config.deploymentMode !== "cloud" || !config.catalogRevision) return; + await recordRequestedCatalogRevision(config); + const [requested] = await db + .select() + .from(billingCatalogRevisions) + .where(eq(billingCatalogRevisions.revision, config.catalogRevision)) + .limit(1); + if (!requested) throw new BillingCatalogUnavailableError(); + if (requested.status === "abandoned") return; + + try { + for (const offer of config.offers) await verifyOffer(provider, offer); + } catch (error) { + logger.error( + { + error: providerErrorSummary(error), + revision: requested.revision, + }, + "billing catalog verification failed", + ); + recordBillingMetric("billing.catalog.invalid", { + revision: requested.revision, + }); + await pageBillingAlert({ + code: "catalog_invalid", + message: + "The billing catalog revision is invalid; checkout is frozen.", + details: { count: 1 }, + }).catch(() => undefined); + if (requested.status !== "active") { + await markRevision(requested.id, "invalid"); + } else { + await markRevision(requested.id, "invalid"); + } + throw new BillingCatalogUnavailableError("billing_catalog_unavailable"); + } + + await db.transaction(async (tx) => { + const [existingRevision] = await tx + .select() + .from(billingCatalogRevisions) + .where(eq(billingCatalogRevisions.id, requested.id)) + .limit(1) + .for("update"); + if (!existingRevision) throw new BillingCatalogUnavailableError(); + const [activeRevision] = await tx + .select() + .from(billingCatalogRevisions) + .where( + and( + eq( + billingCatalogRevisions.checkoutProvider, + provider.provider, + ), + eq(billingCatalogRevisions.status, "active"), + ), + ) + .limit(1) + .for("update"); + if (activeRevision && activeRevision.revision > requested.revision) { + throw new Error("billing_catalog_revision_rollback"); + } + const priceRows = []; + for (const offer of config.offers) { + const [existingPrice] = await tx + .select() + .from(billingPriceEntries) + .where( + and( + eq(billingPriceEntries.provider, offer.provider), + eq( + billingPriceEntries.providerProductId, + offer.providerProductId, + ), + ), + ) + .limit(1) + .for("update"); + if (existingPrice) { + if ( + existingPrice.amountMinor !== offer.amountMinor || + existingPrice.currency !== offer.currency || + existingPrice.billingInterval !== offer.interval || + existingPrice.plan !== offer.plan || + existingPrice.catalogKey !== offer.catalogKey + ) { + throw new Error("billing_provider_product_changed"); + } + await tx + .update(billingPriceEntries) + .set({ verifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(billingPriceEntries.id, existingPrice.id)); + priceRows.push(existingPrice); + } else { + const [created] = await tx + .insert(billingPriceEntries) + .values({ + catalogKey: offer.catalogKey, + plan: offer.plan, + billingInterval: offer.interval, + currency: offer.currency, + amountMinor: offer.amountMinor, + provider: offer.provider, + providerProductId: offer.providerProductId, + verifiedAt: new Date(), + }) + .returning(); + if (!created) + throw new Error("billing_price_entry_unavailable"); + priceRows.push(created); + } + await tx + .insert(billingCatalogRevisionItems) + .values({ + catalogRevisionId: existingRevision.id, + catalogKey: offer.catalogKey, + billingPriceEntryId: priceRows[priceRows.length - 1].id, + }) + .onConflictDoNothing(); + } + if (activeRevision && activeRevision.id !== existingRevision.id) { + await tx + .update(billingCatalogRevisions) + .set({ + status: "retired", + retiredAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(billingCatalogRevisions.id, activeRevision.id)); + } + await tx + .update(billingCatalogRevisions) + .set({ + status: "active", + verifiedAt: new Date(), + activatedAt: existingRevision.activatedAt ?? new Date(), + updatedAt: new Date(), + }) + .where(eq(billingCatalogRevisions.id, existingRevision.id)); + }); + recordBillingMetric("billing.catalog.activated", { + revision: requested.revision, + }); +} + +/** Checkout-time check of the selected product. A mismatch freezes the catalog. */ +export async function verifyCheckoutOffer( + config: BillingConfig, + provider: BillingProviderAdapter, + offer: BillingOffer, +): Promise { + try { + await verifyOffer(provider, offer); + } catch (error) { + logger.error( + { + error: providerErrorSummary(error), + catalog_key: offer.catalogKey, + }, + "billing checkout catalog mismatch", + ); + const [active] = await db + .select({ id: billingCatalogRevisions.id }) + .from(billingCatalogRevisions) + .where( + and( + eq( + billingCatalogRevisions.checkoutProvider, + provider.provider, + ), + eq(billingCatalogRevisions.status, "active"), + ), + ) + .limit(1); + if (active) await markRevision(active.id, "invalid"); + recordBillingMetric("billing.catalog.invalid", { + reason: "checkout_mismatch", + catalog_key: offer.catalogKey, + }); + throw new BillingCatalogUnavailableError(); + } +} + +export async function requireActiveCatalog( + config: BillingConfig, + provider: BillingProviderAdapter, +) { + const active = await getActiveCatalog(config); + if (active.revision.revision !== config.catalogRevision) { + throw new Error("billing_catalog_changed"); + } + return active; +} + +export async function abandonCatalogRevision( + revision: number, + reason: string, +): Promise { + const trimmed = reason.trim(); + if (!trimmed || trimmed.length > 500) + throw new Error("operator_reason_invalid"); + const [row] = await db + .select() + .from(billingCatalogRevisions) + .where(eq(billingCatalogRevisions.revision, revision)) + .limit(1); + if (!row) return false; + if (row.status !== "pending_verification" && row.status !== "invalid") { + throw new Error("billing_catalog_revision_not_abandonable"); + } + await markRevision(row.id, "abandoned"); + recordBillingMetric("billing.catalog.abandoned", { + revision, + reason: trimmed, + }); + return true; +} + +/** @deprecated Prefer recordRequestedCatalogRevision + verifyCatalogAgainstProvider. */ +export async function ensureActiveCatalog( + config: BillingConfig, + provider: BillingProviderAdapter, +) { + await verifyCatalogAgainstProvider(config, provider); + return getActiveCatalog(config); +} diff --git a/apps/api/src/billing/catalog.test.ts b/apps/api/src/billing/catalog.test.ts new file mode 100644 index 0000000..d39b38d --- /dev/null +++ b/apps/api/src/billing/catalog.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + assertBillingProviderConfig, + BillingConfigurationError, + readBillingConfig, + trialHmacSecrets, +} from "./catalog"; + +const keys = [ + "SENDLIT_DEPLOYMENT_MODE", + "BILLING_CHECKOUT_PROVIDER", + "BILLING_ENABLED_PROVIDERS", + "BILLING_CATALOG_REVISION", + "BILLING_CURRENCY", + "BILLING_PRO_MONTH_AMOUNT_MINOR", + "BILLING_PRO_YEAR_AMOUNT_MINOR", + "BILLING_BUSINESS_MONTH_AMOUNT_MINOR", + "BILLING_BUSINESS_YEAR_AMOUNT_MINOR", + "DODO_PRO_MONTH_PRODUCT_ID", + "DODO_PRO_YEAR_PRODUCT_ID", + "DODO_BUSINESS_MONTH_PRODUCT_ID", + "DODO_BUSINESS_YEAR_PRODUCT_ID", + "BILLING_TRIAL_EMAIL_HMAC_KEY", +] as const; +const original = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + +afterEach(() => { + for (const key of keys) { + const value = original[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function cloudEnv(overrides: Record = {}) { + process.env = { + ...process.env, + SENDLIT_DEPLOYMENT_MODE: "cloud", + BILLING_CHECKOUT_PROVIDER: "dodo", + BILLING_ENABLED_PROVIDERS: "dodo", + BILLING_CATALOG_REVISION: "1", + BILLING_CURRENCY: "USD", + BILLING_PRO_MONTH_AMOUNT_MINOR: "4900", + BILLING_PRO_YEAR_AMOUNT_MINOR: "49000", + BILLING_BUSINESS_MONTH_AMOUNT_MINOR: "19900", + BILLING_BUSINESS_YEAR_AMOUNT_MINOR: "199000", + DODO_PRO_MONTH_PRODUCT_ID: "pdt_pro_month", + DODO_PRO_YEAR_PRODUCT_ID: "pdt_pro_year", + DODO_BUSINESS_MONTH_PRODUCT_ID: "pdt_business_month", + DODO_BUSINESS_YEAR_PRODUCT_ID: "pdt_business_year", + BILLING_TRIAL_EMAIL_HMAC_KEY: "trial-hmac-secret", + ...overrides, + }; +} + +describe("readBillingConfig", () => { + it("rejects missing deployment mode", () => { + delete process.env.SENDLIT_DEPLOYMENT_MODE; + expect(() => readBillingConfig()).toThrow(BillingConfigurationError); + }); + + it("rejects fractional amounts", () => { + cloudEnv({ BILLING_PRO_MONTH_AMOUNT_MINOR: "49.00" }); + expect(() => readBillingConfig()).toThrow(/must_be_decimal_integer/); + }); + + it("rejects negative and zero amounts", () => { + cloudEnv({ BILLING_PRO_MONTH_AMOUNT_MINOR: "0" }); + expect(() => readBillingConfig()).toThrow(/out_of_range/); + }); + + it("rejects whitespace-padded amounts", () => { + cloudEnv({ BILLING_PRO_MONTH_AMOUNT_MINOR: " 4900" }); + expect(() => readBillingConfig()).toThrow(/whitespace|decimal/); + }); + + it("rejects duplicate provider products", () => { + cloudEnv({ DODO_PRO_YEAR_PRODUCT_ID: "pdt_pro_month" }); + expect(() => readBillingConfig()).toThrow(/unique/); + }); + + it("parses a valid cloud catalog without exposing floats", () => { + cloudEnv(); + const config = readBillingConfig(); + expect(config.offers).toHaveLength(4); + expect( + config.offers.every((offer) => Number.isInteger(offer.amountMinor)), + ).toBe(true); + }); +}); + +describe("trialHmacSecrets", () => { + it("requires a dedicated HMAC key", () => { + delete process.env.BILLING_TRIAL_EMAIL_HMAC_KEY; + expect(() => trialHmacSecrets()).toThrow( + /BILLING_TRIAL_EMAIL_HMAC_KEY_missing/, + ); + }); +}); + +const fakeProductIds = { + FAKE_PRO_MONTH_PRODUCT_ID: "pdt_pro_month", + FAKE_PRO_YEAR_PRODUCT_ID: "pdt_pro_year", + FAKE_BUSINESS_MONTH_PRODUCT_ID: "pdt_business_month", + FAKE_BUSINESS_YEAR_PRODUCT_ID: "pdt_business_year", +}; + +describe("fake checkout provider", () => { + it("allows cloud tests without Dodo credentials", () => { + cloudEnv({ + BILLING_CHECKOUT_PROVIDER: "fake", + BILLING_ENABLED_PROVIDERS: "fake", + ...fakeProductIds, + }); + expect(() => + assertBillingProviderConfig(readBillingConfig()), + ).not.toThrow(); + }); + + it("refuses the fake adapter in production", () => { + const previous = process.env.NODE_ENV; + cloudEnv({ + BILLING_CHECKOUT_PROVIDER: "fake", + BILLING_ENABLED_PROVIDERS: "fake", + ...fakeProductIds, + }); + process.env.NODE_ENV = "production"; + try { + expect(() => + assertBillingProviderConfig(readBillingConfig()), + ).toThrow(/fake_billing_provider_not_allowed_in_production/); + } finally { + process.env.NODE_ENV = previous; + } + }); +}); diff --git a/apps/api/src/billing/catalog.ts b/apps/api/src/billing/catalog.ts new file mode 100644 index 0000000..24475e5 --- /dev/null +++ b/apps/api/src/billing/catalog.ts @@ -0,0 +1,326 @@ +import { createHmac } from "node:crypto"; + +export const billingCatalogKeys = [ + "pro_month", + "pro_year", + "business_month", + "business_year", +] as const; +export type BillingCatalogKey = (typeof billingCatalogKeys)[number]; + +export type BillingPlan = "oss" | "free" | "pro" | "business"; +export type BillingInterval = "month" | "year"; +export type BillingProvider = "dodo" | (string & {}); +export type BillingDeploymentMode = "oss" | "cloud"; + +export type BillingOffer = { + catalogKey: BillingCatalogKey; + catalogRevision: number; + plan: "pro" | "business"; + interval: BillingInterval; + currency: string; + amountMinor: number; + provider: BillingProvider; + providerProductId: string; + trialDays: number; +}; + +export type BillingConfig = { + deploymentMode: BillingDeploymentMode; + checkoutProvider: BillingProvider | null; + enabledProviders: BillingProvider[]; + catalogRevision: number | null; + currency: string | null; + offers: BillingOffer[]; +}; + +export class BillingConfigurationError extends Error { + constructor(message: string) { + super(`billing_configuration_invalid:${message}`); + this.name = "BillingConfigurationError"; + } +} + +const offerEnv: Record< + BillingCatalogKey, + { + amount: string; + productSuffix: string; + plan: "pro" | "business"; + interval: BillingInterval; + trialDays: number; + } +> = { + pro_month: { + amount: "BILLING_PRO_MONTH_AMOUNT_MINOR", + productSuffix: "PRO_MONTH_PRODUCT_ID", + plan: "pro", + interval: "month", + trialDays: 14, + }, + pro_year: { + amount: "BILLING_PRO_YEAR_AMOUNT_MINOR", + productSuffix: "PRO_YEAR_PRODUCT_ID", + plan: "pro", + interval: "year", + trialDays: 0, + }, + business_month: { + amount: "BILLING_BUSINESS_MONTH_AMOUNT_MINOR", + productSuffix: "BUSINESS_MONTH_PRODUCT_ID", + plan: "business", + interval: "month", + trialDays: 0, + }, + business_year: { + amount: "BILLING_BUSINESS_YEAR_AMOUNT_MINOR", + productSuffix: "BUSINESS_YEAR_PRODUCT_ID", + plan: "business", + interval: "year", + trialDays: 0, + }, +}; + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]; + if (value === undefined || value.length === 0) { + throw new BillingConfigurationError(`${name}_missing`); + } + if (value !== value.trim()) { + throw new BillingConfigurationError(`${name}_whitespace`); + } + return value; +} + +function positiveSafeInteger(value: string, name: string): number { + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new BillingConfigurationError(`${name}_must_be_decimal_integer`); + } + const parsed = Number(value); + if ( + !Number.isSafeInteger(parsed) || + parsed <= 0 || + parsed > 2_147_483_647 + ) { + throw new BillingConfigurationError(`${name}_out_of_range`); + } + return parsed; +} + +function providerList(raw: string | undefined): BillingProvider[] { + if (!raw || raw.length === 0) return []; + if (raw !== raw.trim()) { + throw new BillingConfigurationError( + "BILLING_ENABLED_PROVIDERS_whitespace", + ); + } + const providers = raw.split(",").map((provider) => provider.trim()); + if ( + providers.some( + (provider) => !/^[a-z][a-z0-9_-]{1,31}$/.test(provider), + ) || + new Set(providers).size !== providers.length + ) { + throw new BillingConfigurationError( + "BILLING_ENABLED_PROVIDERS_invalid", + ); + } + return providers; +} + +/** + * Parse the complete billing deployment configuration. This function is + * intentionally pure so startup checks and tests use exactly the same rules. + * Amounts are minor units and are never represented as floating point money. + */ +export function readBillingConfig( + env: NodeJS.ProcessEnv = process.env, +): BillingConfig { + const mode = required(env, "SENDLIT_DEPLOYMENT_MODE"); + if (mode !== "oss" && mode !== "cloud") { + throw new BillingConfigurationError( + "SENDLIT_DEPLOYMENT_MODE_must_be_oss_or_cloud", + ); + } + + const checkoutRaw = env.BILLING_CHECKOUT_PROVIDER; + const checkoutProvider = checkoutRaw + ? required(env, "BILLING_CHECKOUT_PROVIDER") + : null; + const enabledProviders = providerList(env.BILLING_ENABLED_PROVIDERS); + + if (mode === "oss") { + if (checkoutProvider || enabledProviders.length > 0) { + throw new BillingConfigurationError( + "oss_must_not_configure_billing_provider", + ); + } + return { + deploymentMode: "oss", + checkoutProvider: null, + enabledProviders: [], + catalogRevision: null, + currency: null, + offers: [], + }; + } + + if (!checkoutProvider) { + throw new BillingConfigurationError( + "BILLING_CHECKOUT_PROVIDER_missing", + ); + } + if (!enabledProviders.includes(checkoutProvider)) { + throw new BillingConfigurationError( + "checkout_provider_must_be_enabled", + ); + } + const revision = positiveSafeInteger( + required(env, "BILLING_CATALOG_REVISION"), + "BILLING_CATALOG_REVISION", + ); + const currency = required(env, "BILLING_CURRENCY").toUpperCase(); + if (!/^[A-Z]{3}$/.test(currency)) { + throw new BillingConfigurationError("BILLING_CURRENCY_invalid"); + } + + const offers = billingCatalogKeys.map((catalogKey) => { + const definition = offerEnv[catalogKey]; + const productEnvName = + checkoutProvider === "dodo" + ? `DODO_${definition.productSuffix}` + : `${checkoutProvider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_${definition.productSuffix}`; + const productId = required(env, productEnvName); + if (!/^[^\s]{2,256}$/.test(productId)) { + throw new BillingConfigurationError(`${productEnvName}_invalid`); + } + return { + catalogKey, + catalogRevision: revision, + plan: definition.plan, + interval: definition.interval, + currency, + amountMinor: positiveSafeInteger( + required(env, definition.amount), + definition.amount, + ), + provider: checkoutProvider, + providerProductId: productId, + trialDays: definition.trialDays, + } satisfies BillingOffer; + }); + + const productIds = new Set(offers.map((offer) => offer.providerProductId)); + if (productIds.size !== offers.length) { + throw new BillingConfigurationError("provider_products_must_be_unique"); + } + + return { + deploymentMode: "cloud", + checkoutProvider, + enabledProviders, + catalogRevision: revision, + currency, + offers, + }; +} + +export function getBillingOffer( + config: BillingConfig, + plan: "pro" | "business", + interval: BillingInterval, +): BillingOffer | null { + return ( + config.offers.find( + (offer) => offer.plan === plan && offer.interval === interval, + ) ?? null + ); +} + +export type TrialHmacSecret = { version: string; secret: string }; + +export function trialHmacSecrets( + env: NodeJS.ProcessEnv = process.env, +): TrialHmacSecret[] { + const current = env.BILLING_TRIAL_EMAIL_HMAC_KEY?.trim(); + if (!current) { + throw new BillingConfigurationError( + "BILLING_TRIAL_EMAIL_HMAC_KEY_missing", + ); + } + const version = env.BILLING_TRIAL_EMAIL_HMAC_KEY_VERSION?.trim() || "v1"; + const secrets: TrialHmacSecret[] = [{ version, secret: current }]; + const previous = env.BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS?.trim(); + if (previous) { + secrets.push({ + version: + env.BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS_VERSION?.trim() || + "previous", + secret: previous, + }); + } + return secrets; +} + +/** Secrets are checked separately from the public catalog parser so tests and + * pricing pages can inspect a catalog without requiring provider credentials. */ +export function assertBillingProviderConfig( + config: BillingConfig, + env: NodeJS.ProcessEnv = process.env, +): void { + if (config.deploymentMode !== "cloud") return; + trialHmacSecrets(env); + if (config.checkoutProvider === "fake") { + if (env.NODE_ENV === "production") { + throw new BillingConfigurationError( + "fake_billing_provider_not_allowed_in_production", + ); + } + return; + } + if (config.checkoutProvider === "dodo") { + if (!env.DODO_PAYMENTS_API_KEY?.trim()) { + throw new BillingConfigurationError( + "DODO_PAYMENTS_API_KEY_missing", + ); + } + if (!env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT?.trim()) { + throw new BillingConfigurationError( + "DODO_PAYMENTS_WEBHOOK_KEY_CURRENT_missing", + ); + } + const environment = env.DODO_PAYMENTS_ENVIRONMENT; + if (environment !== "test_mode" && environment !== "live_mode") { + throw new BillingConfigurationError( + "DODO_PAYMENTS_ENVIRONMENT_invalid", + ); + } + const previous = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS?.trim(); + const expires = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT; + if (previous) { + if (!expires) { + throw new BillingConfigurationError( + "DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT_missing", + ); + } + const expiry = new Date(expires); + const max = Date.now() + 48 * 60 * 60 * 1000; + if (Number.isNaN(expiry.getTime()) || expiry.getTime() > max) { + throw new BillingConfigurationError( + "DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_must_expire_within_48_hours", + ); + } + } + } +} + +/** Stable email fingerprint helper; callers persist the active key version. */ +export function fingerprintVerifiedEmail( + email: string, + secret: string, +): string { + const normalized = email.trim().toLowerCase(); + return createHmac("sha256", secret) + .update(normalized, "utf8") + .digest("hex"); +} diff --git a/apps/api/src/billing/checkout.ts b/apps/api/src/billing/checkout.ts new file mode 100644 index 0000000..379de08 --- /dev/null +++ b/apps/api/src/billing/checkout.ts @@ -0,0 +1,763 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { db } from "../db/client"; +import { + billingCheckoutAttempts, + billingPriceEntries, + billingProviderCustomers, + billingTrialClaims, + organizationMembers, + organizationPlanStates, + organizationSubscriptions, + organizations, + user, +} from "../db/schema"; +import { encryptBillingValue, decryptBillingValue } from "./crypto"; +import { + fingerprintVerifiedEmail, + getBillingOffer, + readBillingConfig, + trialHmacSecrets, +} from "./catalog"; +import { requireActiveCatalog, verifyCheckoutOffer } from "./catalog-store"; +import { getBillingProvider } from "./provider-registry"; +import { BillingProviderError, providerErrorSummary } from "./provider"; +import { createOrganization } from "../organization/queries"; + +export class BillingCheckoutError extends Error { + constructor( + public readonly code: + | "billing_catalog_changed" + | "billing_catalog_unavailable" + | "billing_provider_unavailable" + | "billing_owner_required" + | "recent_authentication_required" + | "active_subscription_exists" + | "billing_checkout_pending" + | "organization_name_already_exists" + | "payment_required", + public readonly status: 400 | 401 | 402 | 403 | 409 | 503, + public readonly details: Record = {}, + ) { + super(code); + this.name = "BillingCheckoutError"; + } +} + +function errorDetails(config: ReturnType) { + return { + catalogRevision: config.catalogRevision, + currency: config.currency, + offers: config.offers.map( + ({ providerProductId: _providerProductId, ...offer }) => offer, + ), + checkoutAvailable: + config.deploymentMode === "cloud" && config.offers.length === 4, + }; +} + +function expiration(now = new Date()): Date { + return new Date(now.getTime() + 24 * 60 * 60 * 1000); +} + +async function reserveTrialInTransaction( + tx: Parameters[0]>[0], + input: { + userId: string; + email: string; + organizationId: string; + checkoutAttemptId: string; + expiresAt: Date; + }, +): Promise { + const secrets = trialHmacSecrets(); + const fingerprints = secrets.map((secret) => + fingerprintVerifiedEmail(input.email, secret.secret), + ); + const [existing] = await tx + .select() + .from(billingTrialClaims) + .where( + and( + eq(billingTrialClaims.trialKey, "pro_month"), + inArray( + billingTrialClaims.verifiedEmailFingerprint, + fingerprints, + ), + ), + ) + .limit(1) + .for("update"); + const [existingUser] = await tx + .select() + .from(billingTrialClaims) + .where( + and( + eq(billingTrialClaims.userId, input.userId), + eq(billingTrialClaims.trialKey, "pro_month"), + ), + ) + .limit(1) + .for("update"); + const claimed = existing ?? existingUser; + if (claimed?.status === "redeemed" || claimed?.status === "reserved") { + return claimed.checkoutAttemptId === input.checkoutAttemptId; + } + if (claimed?.status === "released") { + const [updated] = await tx + .update(billingTrialClaims) + .set({ + organizationId: input.organizationId, + checkoutAttemptId: input.checkoutAttemptId, + status: "reserved", + expiresAt: input.expiresAt, + verifiedEmailFingerprint: fingerprints[0], + fingerprintKeyVersion: secrets[0].version, + updatedAt: new Date(), + }) + .where( + and( + eq(billingTrialClaims.id, claimed.id), + eq(billingTrialClaims.status, "released"), + ), + ) + .returning(); + return Boolean(updated); + } + const [created] = await tx + .insert(billingTrialClaims) + .values({ + userId: input.userId, + verifiedEmailFingerprint: fingerprints[0], + fingerprintKeyVersion: secrets[0].version, + trialKey: "pro_month", + organizationId: input.organizationId, + checkoutAttemptId: input.checkoutAttemptId, + status: "reserved", + expiresAt: input.expiresAt, + }) + .onConflictDoNothing() + .returning(); + return Boolean(created); +} + +function returnUrl(organizationPublicId: string): string { + // This is server-owned and intentionally has no client-supplied redirect. + // Checkout returns to the dashboard origin (not the API origin), where the + // UI can poll the webhook-backed billing projection. + const webClient = process.env.WEB_CLIENT; + if (!webClient) throw new Error("WEB_CLIENT_missing"); + const params = new URLSearchParams({ + tab: "plan", + billing: "confirming", + organization: organizationPublicId, + }); + return `${new URL(webClient).origin}/organizations?${params.toString()}`; +} + +function cancelUrl(organizationPublicId: string): string { + const webClient = process.env.WEB_CLIENT; + if (!webClient) throw new Error("WEB_CLIENT_missing"); + return `${new URL(webClient).origin}/organizations?tab=plan&organization=${encodeURIComponent(organizationPublicId)}`; +} + +export async function createOrganizationCheckout(input: { + organizationId: string; + payerUserId: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + pendingTeamName?: string; +}) { + let config: ReturnType; + try { + config = readBillingConfig(); + } catch { + throw new BillingCheckoutError("billing_catalog_unavailable", 503); + } + if (config.deploymentMode !== "cloud" || !config.catalogRevision) { + throw new BillingCheckoutError("billing_provider_unavailable", 503); + } + if (input.catalogRevision !== config.catalogRevision) { + throw new BillingCheckoutError( + "billing_catalog_changed", + 409, + errorDetails(config), + ); + } + const offer = getBillingOffer(config, input.plan, input.interval); + if (!offer) + throw new BillingCheckoutError("billing_catalog_unavailable", 503); + + const provider = (() => { + try { + return getBillingProvider(config.checkoutProvider ?? undefined); + } catch { + throw new BillingCheckoutError("billing_provider_unavailable", 503); + } + })(); + let catalog; + try { + catalog = await requireActiveCatalog(config, provider); + await verifyCheckoutOffer(config, provider, offer); + } catch (error) { + if ( + error instanceof Error && + error.message === "billing_catalog_changed" + ) { + throw new BillingCheckoutError( + "billing_catalog_changed", + 409, + errorDetails(config), + ); + } + throw new BillingCheckoutError("billing_catalog_unavailable", 503); + } + const price = catalog.items.find( + (row) => row.catalogKey === offer.catalogKey, + )?.price; + if (!price) + throw new BillingCheckoutError("billing_catalog_unavailable", 503); + + const [identity] = await db + .select({ + id: user.id, + email: user.email, + name: user.name, + emailVerified: user.emailVerified, + }) + .from(user) + .where(eq(user.id, input.payerUserId)) + .limit(1); + if (!identity?.emailVerified) { + throw new BillingCheckoutError("billing_owner_required", 403, { + reason: "verified_email_required", + }); + } + + const [organization] = await db + .select({ organizationId: organizations.organizationId }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1); + if (!organization) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + const now = new Date(); + // The durable attempt row owns the provider idempotency key. Repeated + // requests while an attempt is open return that attempt's URL; a fresh + // random suffix is generated only after the previous attempt is terminal. + const attemptKeyPrefix = `checkout:${input.organizationId}:${input.payerUserId}:${offer.catalogKey}:${config.catalogRevision}`; + const pending = await db.transaction(async (tx) => { + const [observedPlanState] = await tx + .select() + .from(organizationPlanStates) + .where( + eq(organizationPlanStates.organizationId, input.organizationId), + ) + .limit(1); + let subscription: + | Pick< + typeof organizationSubscriptions.$inferSelect, + "id" | "status" | "paidThroughAt" | "cancelAtPeriodEnd" + > + | undefined; + if (observedPlanState?.activeSubscriptionId) { + [subscription] = await tx + .select({ + id: organizationSubscriptions.id, + status: organizationSubscriptions.status, + paidThroughAt: organizationSubscriptions.paidThroughAt, + cancelAtPeriodEnd: + organizationSubscriptions.cancelAtPeriodEnd, + }) + .from(organizationSubscriptions) + .where( + eq( + organizationSubscriptions.id, + observedPlanState.activeSubscriptionId, + ), + ) + .limit(1) + .for("update"); + } + const [existing] = await tx + .select() + .from(billingCheckoutAttempts) + .where( + and( + eq( + billingCheckoutAttempts.organizationId, + input.organizationId, + ), + inArray(billingCheckoutAttempts.status, [ + "creating", + "open", + ]), + ), + ) + .limit(1) + .for("update"); + const [lockedOrganization] = await tx + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1) + .for("update"); + if ( + !lockedOrganization || + !["active", "pending_payment"].includes(lockedOrganization.status) + ) { + throw new BillingCheckoutError("billing_owner_required", 403); + } + const [planState] = await tx + .select() + .from(organizationPlanStates) + .where( + eq(organizationPlanStates.organizationId, input.organizationId), + ) + .limit(1) + .for("update"); + if (!planState) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + if ( + planState.activeSubscriptionId !== + (observedPlanState?.activeSubscriptionId ?? null) + ) { + throw new BillingCheckoutError("billing_checkout_pending", 409); + } + if (planState.activeSubscriptionId) { + if ( + subscription && + (["pending", "trialing", "active", "past_due"].includes( + subscription.status, + ) || + Boolean( + subscription.cancelAtPeriodEnd && + subscription.paidThroughAt && + subscription.paidThroughAt > now, + )) + ) { + throw new BillingCheckoutError( + "active_subscription_exists", + 409, + ); + } + // Detach an elapsed or immediately-cancelled source in the same + // transaction that creates its replacement checkout. Otherwise a + // payment completed before the hourly expiry sweep would be + // quarantined as a conflicting live subscription. + if (subscription) { + await tx + .update(organizationSubscriptions) + .set({ isEntitlementSource: false, updatedAt: now }) + .where(eq(organizationSubscriptions.id, subscription.id)); + } + await tx + .update(organizationPlanStates) + .set({ + plan: "free", + activeSubscriptionId: null, + projectionVersion: planState.projectionVersion + 1, + updatedAt: now, + }) + .where(eq(organizationPlanStates.id, planState.id)); + } + if (existing && existing.expiresAt > now) { + if (existing.checkoutUrlEncrypted) { + return { existing }; + } + throw new BillingCheckoutError("billing_checkout_pending", 409); + } + const attemptKey = `${attemptKeyPrefix}:${randomUUID()}`; + if (existing) { + await tx + .update(billingCheckoutAttempts) + .set({ + status: "expired", + completedAt: now, + updatedAt: now, + checkoutUrlEncrypted: null, + }) + .where(eq(billingCheckoutAttempts.id, existing.id)); + } + + let [customer] = await tx + .select() + .from(billingProviderCustomers) + .where( + and( + eq(billingProviderCustomers.provider, provider.provider), + eq(billingProviderCustomers.userId, input.payerUserId), + ), + ) + .limit(1) + .for("update"); + if (!customer) { + [customer] = await tx + .insert(billingProviderCustomers) + .values({ + provider: provider.provider, + userId: input.payerUserId, + idempotencyKey: `customer:${provider.provider}:${input.payerUserId}`, + status: "creating", + }) + .onConflictDoNothing() + .returning(); + if (!customer) { + [customer] = await tx + .select() + .from(billingProviderCustomers) + .where( + and( + eq( + billingProviderCustomers.provider, + provider.provider, + ), + eq( + billingProviderCustomers.userId, + input.payerUserId, + ), + ), + ) + .limit(1) + .for("update"); + } + } + if (!customer) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + const [attempt] = await tx + .insert(billingCheckoutAttempts) + .values({ + organizationId: input.organizationId, + payerUserId: input.payerUserId, + provider: provider.provider, + catalogRevision: config.catalogRevision!, + catalogKey: offer.catalogKey, + requestedPlan: offer.plan, + requestedInterval: offer.interval, + pendingTeamName: input.pendingTeamName ?? null, + billingPriceEntryId: price.id, + quotedAmountMinor: offer.amountMinor, + quotedCurrency: offer.currency, + billingCustomerId: customer.id, + idempotencyKey: attemptKey, + status: "creating", + expiresAt: expiration(now), + }) + .returning(); + if (!attempt) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + let trialEligible = false; + if (offer.trialDays > 0) { + trialEligible = await reserveTrialInTransaction(tx, { + userId: identity.id, + email: identity.email, + organizationId: input.organizationId, + checkoutAttemptId: attempt.id, + expiresAt: attempt.expiresAt, + }); + } + return { + attempt, + customer, + trialDays: trialEligible ? offer.trialDays : 0, + }; + }); + + if ("existing" in pending && pending.existing) { + try { + return { + checkoutUrl: decryptBillingValue( + pending.existing.checkoutUrlEncrypted!, + ), + expiresAt: pending.existing.expiresAt.toISOString(), + }; + } catch { + throw new BillingCheckoutError("billing_checkout_pending", 409); + } + } + + const { attempt, customer, trialDays } = pending; + let customerId = customer.providerCustomerId; + try { + if (!customerId) { + const created = await provider.createCustomer({ + email: identity.email, + name: identity.name, + idempotencyKey: customer.idempotencyKey, + }); + customerId = created.providerCustomerId; + await db + .update(billingProviderCustomers) + .set({ + providerCustomerId: customerId, + status: "active", + updatedAt: new Date(), + lastError: null, + }) + .where(eq(billingProviderCustomers.id, customer.id)); + } + const checkout = await provider.createCheckout({ + productId: offer.providerProductId, + currency: offer.currency, + customerId, + payerEmail: identity.email, + returnUrl: returnUrl(organization.organizationId), + cancelUrl: cancelUrl(organization.organizationId), + attemptId: attempt.attemptId, + catalogKey: offer.catalogKey, + trialDays, + idempotencyKey: attempt.idempotencyKey, + }); + await db + .update(billingCheckoutAttempts) + .set({ + providerCheckoutSessionId: checkout.providerCheckoutSessionId, + checkoutUrlEncrypted: encryptBillingValue(checkout.checkoutUrl), + status: "open", + updatedAt: new Date(), + }) + .where(eq(billingCheckoutAttempts.id, attempt.id)); + return { + checkoutUrl: checkout.checkoutUrl, + expiresAt: attempt.expiresAt.toISOString(), + }; + } catch (error) { + // Only an explicitly definitive provider rejection can safely abandon + // the attempt. Unknown/network errors may have reached the provider, + // so leave the row creating for reconciliation with the same key. + const ambiguous = + !(error instanceof BillingProviderError) || + error.code === "unavailable" || + error.code === "rate_limited"; + await db + .update(billingCheckoutAttempts) + .set({ + status: ambiguous ? "creating" : "abandoned", + completedAt: ambiguous ? null : new Date(), + lastError: providerErrorSummary(error), + updatedAt: new Date(), + }) + .where(eq(billingCheckoutAttempts.id, attempt.id)); + await db + .update(billingProviderCustomers) + .set({ + status: customerId ? "active" : "creating", + lastError: providerErrorSummary(error), + updatedAt: new Date(), + }) + .where(eq(billingProviderCustomers.id, customer.id)); + if (!ambiguous) { + await db + .update(billingTrialClaims) + .set({ status: "released", updatedAt: new Date() }) + .where( + and( + eq(billingTrialClaims.checkoutAttemptId, attempt.id), + eq(billingTrialClaims.status, "reserved"), + ), + ); + } + if (error instanceof BillingCheckoutError) throw error; + throw new BillingCheckoutError("billing_provider_unavailable", 503); + } +} + +/** Resume a durable `creating` checkout after an API/provider timeout. The + * original attempt and provider idempotency key are reused; no new checkout + * row is created and an ambiguous provider response cannot create a second + * subscription. */ +export async function resumeOrganizationCheckoutAttempt( + attemptId: string, + now = new Date(), +): Promise { + const [row] = await db + .select({ + attempt: billingCheckoutAttempts, + customer: billingProviderCustomers, + organizationPublicId: organizations.organizationId, + email: user.email, + name: user.name, + price: billingPriceEntries, + }) + .from(billingCheckoutAttempts) + .innerJoin( + billingProviderCustomers, + eq( + billingProviderCustomers.id, + billingCheckoutAttempts.billingCustomerId, + ), + ) + .innerJoin( + organizations, + eq(organizations.id, billingCheckoutAttempts.organizationId), + ) + .innerJoin(user, eq(user.id, billingCheckoutAttempts.payerUserId)) + .innerJoin( + billingPriceEntries, + eq( + billingPriceEntries.id, + billingCheckoutAttempts.billingPriceEntryId, + ), + ) + .where(eq(billingCheckoutAttempts.id, attemptId)) + .limit(1); + if ( + !row || + row.attempt.status !== "creating" || + row.attempt.expiresAt <= now + ) + return false; + const provider = getBillingProvider(row.attempt.provider); + let customerId = row.customer.providerCustomerId; + try { + if (!customerId) { + const created = await provider.createCustomer({ + email: row.email, + name: row.name, + idempotencyKey: row.customer.idempotencyKey, + }); + customerId = created.providerCustomerId; + await db + .update(billingProviderCustomers) + .set({ + providerCustomerId: customerId, + status: "active", + updatedAt: now, + lastError: null, + }) + .where(eq(billingProviderCustomers.id, row.customer.id)); + } + const config = readBillingConfig(); + const configuredOffer = getBillingOffer( + config, + row.attempt.requestedPlan as "pro" | "business", + row.attempt.requestedInterval as "month" | "year", + ); + let trialDays = 0; + if (configuredOffer?.trialDays) { + const eligible = await db.transaction((tx) => + reserveTrialInTransaction(tx, { + userId: row.attempt.payerUserId, + email: row.email, + organizationId: row.attempt.organizationId, + checkoutAttemptId: row.attempt.id, + expiresAt: row.attempt.expiresAt, + }), + ); + if (eligible) trialDays = configuredOffer.trialDays; + } + const checkout = await provider.createCheckout({ + productId: row.price.providerProductId, + currency: row.price.currency, + customerId, + payerEmail: row.email, + returnUrl: returnUrl(row.organizationPublicId), + cancelUrl: cancelUrl(row.organizationPublicId), + attemptId: row.attempt.attemptId, + catalogKey: row.attempt.catalogKey, + trialDays, + idempotencyKey: row.attempt.idempotencyKey, + }); + await db + .update(billingCheckoutAttempts) + .set({ + providerCheckoutSessionId: checkout.providerCheckoutSessionId, + checkoutUrlEncrypted: encryptBillingValue(checkout.checkoutUrl), + status: "open", + updatedAt: now, + lastError: null, + }) + .where(eq(billingCheckoutAttempts.id, row.attempt.id)); + return true; + } catch (error) { + await db + .update(billingCheckoutAttempts) + .set({ lastError: providerErrorSummary(error), updatedAt: now }) + .where(eq(billingCheckoutAttempts.id, row.attempt.id)); + throw error; + } +} + +export async function createPaidOrganizationCheckout(input: { + payerUserId: string; + organizationName: string; + teamName: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; +}) { + let config: ReturnType; + try { + config = readBillingConfig(); + } catch { + throw new BillingCheckoutError("billing_catalog_unavailable", 503); + } + if (config.deploymentMode !== "cloud" || !config.catalogRevision) { + throw new BillingCheckoutError("billing_provider_unavailable", 503); + } + if (input.catalogRevision !== config.catalogRevision) { + throw new BillingCheckoutError( + "billing_catalog_changed", + 409, + errorDetails(config), + ); + } + let organization; + try { + organization = await createOrganization( + input.payerUserId, + input.organizationName, + { + pendingPayment: true, + }, + ); + } catch (error) { + if ( + error instanceof Error && + error.message === "organization_name_already_exists" + ) { + throw new BillingCheckoutError( + "organization_name_already_exists", + 409, + ); + } + if ( + error instanceof Error && + error.message === "pending_organization_exists" + ) { + const [existing] = await db + .select({ organization: organizations }) + .from(organizations) + .innerJoin( + organizationMembers, + eq(organizationMembers.organizationId, organizations.id), + ) + .where( + and( + eq(organizationMembers.userId, input.payerUserId), + eq(organizationMembers.role, "owner"), + eq(organizations.status, "pending_payment"), + ), + ) + .limit(1); + if (!existing?.organization) throw error; + organization = existing.organization; + } else { + throw error; + } + } + try { + const checkout = await createOrganizationCheckout({ + organizationId: organization.id, + payerUserId: input.payerUserId, + plan: input.plan, + interval: input.interval, + catalogRevision: input.catalogRevision, + pendingTeamName: input.teamName, + }); + return { ...checkout, organizationId: organization.organizationId }; + } catch (error) { + throw error; + } +} diff --git a/apps/api/src/billing/crypto.test.ts b/apps/api/src/billing/crypto.test.ts new file mode 100644 index 0000000..524c50c --- /dev/null +++ b/apps/api/src/billing/crypto.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { decryptBillingValue, encryptBillingValue } from "./crypto"; + +const originalKey = process.env.BILLING_DATA_ENCRYPTION_KEY; +const originalPreviousKey = process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS; +beforeEach(() => { + process.env.BILLING_DATA_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString( + "base64", + ); +}); +afterEach(() => { + if (originalKey === undefined) + delete process.env.BILLING_DATA_ENCRYPTION_KEY; + else process.env.BILLING_DATA_ENCRYPTION_KEY = originalKey; + if (originalPreviousKey === undefined) + delete process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS; + else process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS = originalPreviousKey; +}); + +describe("billing ciphertext", () => { + it("round trips with an exact 32-byte base64 key", () => { + const value = encryptBillingValue("provider checkout URL"); + expect(decryptBillingValue(value)).toBe("provider checkout URL"); + }); + + it("rejects ambiguous non-base64 or wrong-length keys", () => { + process.env.BILLING_DATA_ENCRYPTION_KEY = "a".repeat(32); + expect(() => encryptBillingValue("secret")).toThrow("must_be_32_bytes"); + process.env.BILLING_DATA_ENCRYPTION_KEY = "not base64!"; + expect(() => encryptBillingValue("secret")).toThrow("must_be_base64"); + }); + + it("decrypts with the previous key during rotation", () => { + const oldKey = Buffer.alloc(32, 8).toString("base64"); + process.env.BILLING_DATA_ENCRYPTION_KEY = oldKey; + const value = encryptBillingValue("rotating secret"); + process.env.BILLING_DATA_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString( + "base64", + ); + process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS = oldKey; + expect(decryptBillingValue(value)).toBe("rotating secret"); + delete process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS; + }); +}); diff --git a/apps/api/src/billing/crypto.ts b/apps/api/src/billing/crypto.ts new file mode 100644 index 0000000..09933e0 --- /dev/null +++ b/apps/api/src/billing/crypto.ts @@ -0,0 +1,78 @@ +import crypto from "node:crypto"; + +const algorithm = "aes-256-gcm"; + +function parseKey(raw: string | undefined, missingCode: string): Buffer { + if (!raw) throw new Error(missingCode); + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + raw, + ) + ) { + throw new Error("BILLING_DATA_ENCRYPTION_KEY_must_be_base64"); + } + const base64 = Buffer.from(raw, "base64"); + if (base64.length === 32) return base64; + throw new Error("BILLING_DATA_ENCRYPTION_KEY_must_be_32_bytes"); +} + +function key(): Buffer { + return parseKey( + process.env.BILLING_DATA_ENCRYPTION_KEY, + "BILLING_DATA_ENCRYPTION_KEY_missing", + ); +} + +export function assertBillingEncryptionKeyConfigured(): void { + key(); +} + +export function encryptBillingValue(value: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(algorithm, key(), iv); + const ciphertext = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final(), + ]); + return [iv, cipher.getAuthTag(), ciphertext] + .map((part) => part.toString("base64")) + .join("."); +} + +export function decryptBillingValue(payload: string): string { + const [iv, tag, ciphertext] = payload.split("."); + if (!iv || !tag || !ciphertext) + throw new Error("billing_ciphertext_invalid"); + const ivBytes = Buffer.from(iv, "base64"); + const tagBytes = Buffer.from(tag, "base64"); + const ciphertextBytes = Buffer.from(ciphertext, "base64"); + const keys = [key()]; + if (process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS) { + keys.push( + parseKey( + process.env.BILLING_DATA_ENCRYPTION_KEY_PREVIOUS, + "BILLING_DATA_ENCRYPTION_KEY_PREVIOUS_invalid", + ), + ); + } + let lastError: unknown; + for (const candidate of keys) { + try { + const decipher = crypto.createDecipheriv( + algorithm, + candidate, + ivBytes, + ); + decipher.setAuthTag(tagBytes); + return Buffer.concat([ + decipher.update(ciphertextBytes), + decipher.final(), + ]).toString("utf8"); + } catch (error) { + lastError = error; + } + } + const failure = new Error("billing_ciphertext_authentication_failed"); + (failure as Error & { cause?: unknown }).cause = lastError; + throw failure; +} diff --git a/apps/api/src/billing/domains.ts b/apps/api/src/billing/domains.ts new file mode 100644 index 0000000..2dc7c00 --- /dev/null +++ b/apps/api/src/billing/domains.ts @@ -0,0 +1,368 @@ +import { randomBytes, createHmac, timingSafeEqual } from "node:crypto"; +import { isIP } from "node:net"; +import { promises as dns } from "node:dns"; +import { and, count, eq, gte } from "drizzle-orm"; +import { db } from "../db/client"; +import { + espFeedbackConnections, + organizationAuditEvents, + organizationMembers, + outboundMessages, + sendingDomains, + teams, + user, +} from "../db/schema"; +import { PlanGateError } from "./errors"; + +function verificationKey(): string { + const key = + process.env.BILLING_DOMAIN_VERIFICATION_KEY || + process.env.BETTER_AUTH_SECRET; + if (!key) throw new Error("BILLING_DOMAIN_VERIFICATION_KEY_missing"); + return key; +} + +export function normalizeSendingDomain(input: string): string { + const value = input.trim().toLowerCase().replace(/\.$/, ""); + if (!value || value.includes("*") || /[:\/\s]/.test(value) || isIP(value)) + throw new Error("domain_invalid"); + let ascii: string; + try { + const parsed = new URL(`http://${value}`); + // URL parsing is useful for IDNA conversion, but it otherwise accepts + // userinfo, ports, paths, and query fragments. None of those are a + // DNS domain and accepting them would verify a different hostname. + if ( + parsed.username || + parsed.password || + parsed.port || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + throw new Error("domain_invalid"); + } + ascii = parsed.hostname.toLowerCase().replace(/\.$/, ""); + } catch { + throw new Error("domain_invalid"); + } + if ( + ascii.length > 253 || + ascii.split(".").length < 2 || + ascii + .split(".") + .some( + (label) => + !label || + label.length > 63 || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ) + ) { + throw new Error("domain_invalid"); + } + // A registrable domain must have a suffix; this conservative guard keeps + // obvious public-suffix inputs out without introducing a mutable PSL at + // runtime. Subdomains (e.g. mail.example.com) remain valid. + const suffix = ascii.split(".").at(-1)!; + if (suffix.length < 2 || /^[0-9]+$/.test(suffix)) + throw new Error("domain_public_suffix"); + return ascii; +} + +function hashChallenge(token: string): string { + return createHmac("sha256", verificationKey()) + .update(token, "utf8") + .digest("hex"); +} + +function resolveTxtWithTimeout( + name: string, + timeoutMs = 5_000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("dns_timeout")), + timeoutMs, + ); + dns.resolveTxt(name).then( + (records) => { + clearTimeout(timer); + resolve(records); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +export function serializeSendingDomain( + row: typeof sendingDomains.$inferSelect, + challengeToken: string | null = null, +) { + const challenge = challengeToken + ? `_sendlit-verification.${row.domain}` + : null; + return { + domainId: row.domainId, + domain: row.domain, + status: row.status as "pending" | "verified" | "revoked" | "failed", + verifiedAt: row.verifiedAt?.toISOString() ?? null, + lastCheckedAt: row.lastCheckedAt?.toISOString() ?? null, + nextCheckAt: row.nextCheckAt?.toISOString() ?? null, + challengeToken, + challengeRecordName: challenge, + challengeRecordValue: challengeToken, + }; +} + +export async function listSendingDomains(organizationId: string) { + return db + .select() + .from(sendingDomains) + .where(eq(sendingDomains.organizationId, organizationId)); +} + +export async function createSendingDomain( + organizationId: string, + input: string, +) { + const domain = normalizeSendingDomain(input); + const token = randomBytes(32).toString("base64url"); + const [row] = await db + .insert(sendingDomains) + .values({ + organizationId, + domain, + challengeTokenHash: hashChallenge(token), + status: "pending", + }) + .returning(); + return { row, token }; +} + +async function recordFailedVerification( + row: typeof sendingDomains.$inferSelect, + now: Date, +) { + const failedCheckCount = row.failedCheckCount + 1; + const firstFailedAt = row.firstFailedAt ?? now; + const revoke = + row.status === "verified" && + failedCheckCount >= 3 && + firstFailedAt.getTime() <= now.getTime() - 72 * 60 * 60 * 1000; + const nextStatus = revoke + ? "revoked" + : row.status === "verified" + ? "verified" + : "failed"; + const updated = await db.transaction(async (tx) => { + const [next] = await tx + .update(sendingDomains) + .set({ + status: nextStatus, + lastCheckedAt: now, + nextCheckAt: new Date( + now.getTime() + (revoke ? 30 : 1) * 24 * 60 * 60 * 1000, + ), + failedCheckCount, + firstFailedAt, + updatedAt: now, + }) + .where(eq(sendingDomains.id, row.id)) + .returning(); + if (next && (failedCheckCount === 1 || revoke)) { + await tx.insert(organizationAuditEvents).values({ + organizationId: row.organizationId, + actorType: "system", + action: revoke + ? "sending_domain.revoked_after_failed_checks" + : "sending_domain.verification_check_failed", + metadata: { + domain: row.domain, + failedCheckCount, + firstFailedAt: firstFailedAt.toISOString(), + }, + }); + } + return next; + }); + return { + row: updated ?? { + ...row, + status: nextStatus, + lastCheckedAt: now, + nextCheckAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + failedCheckCount, + firstFailedAt, + }, + verified: false, + }; +} + +export async function verifySendingDomain( + organizationId: string, + domainId: string, +) { + const [row] = await db + .select() + .from(sendingDomains) + .where( + and( + eq(sendingDomains.organizationId, organizationId), + eq(sendingDomains.domainId, domainId), + ), + ) + .limit(1); + if (!row) return null; + if (row.status === "revoked") return { row, verified: false }; + let records: string[][]; + try { + // Node's resolver has no per-request timeout. Bound the lookup so a + // nameserver or network failure cannot pin an API worker indefinitely. + records = await resolveTxtWithTimeout( + `_sendlit-verification.${row.domain}`, + ); + } catch { + return recordFailedVerification(row, new Date()); + } + const verified = records + .map((chunks) => chunks.join("")) + .some((value) => { + const actual = Buffer.from(hashChallenge(value), "hex"); + const expected = Buffer.from(row.challengeTokenHash, "hex"); + return ( + actual.length === expected.length && + timingSafeEqual(actual, expected) + ); + }); + const now = new Date(); + if (!verified) return recordFailedVerification(row, now); + const [updated] = await db + .update(sendingDomains) + .set({ + status: "verified", + verifiedAt: row.verifiedAt ?? now, + lastCheckedAt: now, + nextCheckAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + failedCheckCount: 0, + firstFailedAt: null, + updatedAt: now, + }) + .where(eq(sendingDomains.id, row.id)) + .returning(); + return { row: updated ?? row, verified }; +} + +export async function revokeSendingDomain( + organizationId: string, + domainId: string, +): Promise { + const [updated] = await db + .update(sendingDomains) + .set({ status: "revoked", updatedAt: new Date() }) + .where( + and( + eq(sendingDomains.organizationId, organizationId), + eq(sendingDomains.domainId, domainId), + ), + ) + .returning({ id: sendingDomains.id }); + return Boolean(updated); +} + +/** + * Cloud test volume is intentionally small, but once crossed we require an + * exact verified From domain. This check is performed at the delivery-source + * boundary so REST, MCP, workers, and future send paths share one policy. + */ +export async function assertSendingEligibility( + organizationId: string, + fromEmail: string, + teamId?: string, + espConfigId?: string, +): Promise { + if (process.env.SENDLIT_DEPLOYMENT_MODE !== "cloud") return; + const [owner] = await db + .select({ id: organizationMembers.id }) + .from(organizationMembers) + .innerJoin(user, eq(user.id, organizationMembers.userId)) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + eq(organizationMembers.role, "owner"), + eq(user.emailVerified, true), + ), + ) + .limit(1); + if (!owner) + throw new PlanGateError("sending_paused", { + organizationId, + reason: "verified_owner_email_required", + }); + + const thresholdRaw = process.env.BILLING_TEST_VOLUME_THRESHOLD; + const threshold = + thresholdRaw === undefined || thresholdRaw === "" + ? 100 + : Number(thresholdRaw); + if (!Number.isSafeInteger(threshold) || threshold <= 0) + throw new Error("BILLING_TEST_VOLUME_THRESHOLD_invalid"); + const [usage] = await db + .select({ value: count() }) + .from(outboundMessages) + .innerJoin(teams, eq(teams.id, outboundMessages.teamId)) + .where( + and( + eq(teams.organizationId, organizationId), + gte(outboundMessages.acceptedAt, new Date(0)), + ), + ); + if (Number(usage?.value ?? 0) < threshold) return; + + if (teamId && espConfigId) { + const [feedback] = await db + .select({ id: espFeedbackConnections.id }) + .from(espFeedbackConnections) + .where( + and( + eq(espFeedbackConnections.teamId, teamId), + eq(espFeedbackConnections.espConfigId, espConfigId), + eq(espFeedbackConnections.status, "healthy"), + ), + ) + .limit(1); + if (!feedback) + throw new PlanGateError("sending_paused", { + organizationId, + reason: "feedback_connection_required", + }); + } + + const at = fromEmail.lastIndexOf("@"); + let domain: string; + try { + domain = normalizeSendingDomain(fromEmail.slice(at + 1)); + } catch { + throw new PlanGateError("domain_verification_required", { + organizationId, + }); + } + const [verified] = await db + .select({ id: sendingDomains.id }) + .from(sendingDomains) + .where( + and( + eq(sendingDomains.organizationId, organizationId), + eq(sendingDomains.domain, domain), + eq(sendingDomains.status, "verified"), + ), + ) + .limit(1); + if (!verified) + throw new PlanGateError("domain_verification_required", { + organizationId, + domain, + }); +} diff --git a/apps/api/src/billing/entitlements.test.ts b/apps/api/src/billing/entitlements.test.ts new file mode 100644 index 0000000..82e93c7 --- /dev/null +++ b/apps/api/src/billing/entitlements.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); + +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingProviderCustomers, + billingPriceEntries, + organizationPlanStates, + organizationSubscriptions, + outboundMessages, + planSendReservations, + planSendUsageBuckets, +} from "../db/schema"; +import { seedTeamAndContact, truncateAll, type TestDb } from "../test/db"; +import { expireCancelledSubscriptionEntitlements } from "./webhooks/processor"; +import { + commitSendReservation, + reserveSend, + settleExpiredSendReservation, +} from "./entitlements"; + +const tdb = db as unknown as TestDb; + +beforeEach(async () => { + process.env.SENDLIT_DEPLOYMENT_MODE = "cloud"; + await truncateAll(tdb); +}); + +async function fixture() { + const { organization, team } = await seedTeamAndContact(tdb); + const [outbound] = await tdb + .insert(outboundMessages) + .values({ + teamId: team.id, + deliverySourceType: "team", + sourceType: "campaign", + recipientEmail: "reader@example.com", + normalizedRecipient: "reader@example.com", + deliveryStatus: "prepared", + }) + .returning(); + return { organization, outbound }; +} + +describe("send usage reservations", () => { + it("is idempotent for live and committed reservation retries", async () => { + const { organization, outbound } = await fixture(); + const reserve = () => + tdb.transaction((tx) => + reserveSend(tx as any, { + organizationId: organization.id, + outboundMessageId: outbound.id, + purpose: "marketing", + }), + ); + + await reserve(); + await reserve(); + let [bucket] = await tdb.select().from(planSendUsageBuckets); + expect(bucket.reserved).toBe(1); + + await commitSendReservation(outbound.id); + await reserve(); + [bucket] = await tdb.select().from(planSendUsageBuckets); + expect(bucket).toMatchObject({ reserved: 0, committed: 1 }); + }); + + it("releases an expired reservation before reopening it", async () => { + const { organization, outbound } = await fixture(); + const reserve = () => + tdb.transaction((tx) => + reserveSend(tx as any, { + organizationId: organization.id, + outboundMessageId: outbound.id, + purpose: "marketing", + }), + ); + + await reserve(); + await tdb + .update(planSendReservations) + .set({ expiresAt: new Date(Date.now() - 1_000) }) + .where(eq(planSendReservations.outboundMessageId, outbound.id)); + await reserve(); + + const [bucket] = await tdb.select().from(planSendUsageBuckets); + const [reservation] = await tdb.select().from(planSendReservations); + expect(bucket.reserved).toBe(1); + expect(reservation.state).toBe("reserved"); + expect(reservation.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it("commits expired quota when the provider acceptance was already recorded", async () => { + const { organization, outbound } = await fixture(); + await tdb.transaction((tx) => + reserveSend(tx as any, { + organizationId: organization.id, + outboundMessageId: outbound.id, + purpose: "marketing", + }), + ); + await tdb + .update(outboundMessages) + .set({ deliveryStatus: "accepted", acceptedAt: new Date() }) + .where(eq(outboundMessages.id, outbound.id)); + await tdb + .update(planSendReservations) + .set({ expiresAt: new Date(Date.now() - 1_000) }) + .where(eq(planSendReservations.outboundMessageId, outbound.id)); + + await settleExpiredSendReservation(outbound.id); + + const [bucket] = await tdb.select().from(planSendUsageBuckets); + const [reservation] = await tdb.select().from(planSendReservations); + expect(bucket).toMatchObject({ reserved: 0, committed: 1 }); + expect(reservation.state).toBe("committed"); + }); + + it("releases an expired reservation when the outbound was not accepted", async () => { + const { organization, outbound } = await fixture(); + await tdb.transaction((tx) => + reserveSend(tx as any, { + organizationId: organization.id, + outboundMessageId: outbound.id, + purpose: "marketing", + }), + ); + await tdb + .update(planSendReservations) + .set({ expiresAt: new Date(Date.now() - 1_000) }) + .where(eq(planSendReservations.outboundMessageId, outbound.id)); + + await settleExpiredSendReservation(outbound.id); + + const [bucket] = await tdb.select().from(planSendUsageBuckets); + const [reservation] = await tdb.select().from(planSendReservations); + expect(bucket).toMatchObject({ reserved: 0, committed: 0 }); + expect(reservation.state).toBe("released"); + }); +}); + +describe("scheduled cancellation expiry", () => { + async function seedSubscription(status: { + cancelAtPeriodEnd: boolean; + paidThroughAt: Date; + isEntitlementSource?: boolean; + }) { + const { account, organization } = await seedTeamAndContact(tdb); + const [price] = await tdb + .insert(billingPriceEntries) + .values({ + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + currency: "USD", + amountMinor: 4900, + provider: "dodo", + providerProductId: `pdt_${crypto.randomUUID()}`, + }) + .returning(); + const [customer] = await tdb + .insert(billingProviderCustomers) + .values({ + provider: "dodo", + userId: account.id, + providerCustomerId: `cus_${crypto.randomUUID()}`, + idempotencyKey: `customer:dodo:${account.id}`, + status: "active", + }) + .returning(); + const [subscription] = await tdb + .insert(organizationSubscriptions) + .values({ + organizationId: organization.id, + billingCustomerId: customer.id, + billingManagerUserId: account.id, + provider: "dodo", + providerSubscriptionId: `sub_${crypto.randomUUID()}`, + providerProductId: price.providerProductId, + billingPriceEntryId: price.id, + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + status: "cancelled", + paidThroughAt: status.paidThroughAt, + cancelAtPeriodEnd: status.cancelAtPeriodEnd, + isEntitlementSource: status.isEntitlementSource ?? true, + }) + .returning(); + await tdb + .update(organizationPlanStates) + .set({ + plan: "pro", + activeSubscriptionId: subscription.id, + }) + .where(eq(organizationPlanStates.organizationId, organization.id)); + return { organization, subscription }; + } + + it("keeps scheduled cancellation paid until the verified paid-through time", async () => { + const { organization } = await seedSubscription({ + cancelAtPeriodEnd: true, + paidThroughAt: new Date(Date.now() + 60_000), + }); + expect(await expireCancelledSubscriptionEntitlements()).toBe(0); + const [state] = await tdb + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, organization.id)); + expect(state).toMatchObject({ + plan: "pro", + activeSubscriptionId: expect.any(String), + }); + }); + + it("projects elapsed scheduled cancellation to Free", async () => { + const { organization, subscription } = await seedSubscription({ + cancelAtPeriodEnd: true, + paidThroughAt: new Date(Date.now() - 60_000), + }); + expect(await expireCancelledSubscriptionEntitlements()).toBe(1); + const [state] = await tdb + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, organization.id)); + const [row] = await tdb + .select() + .from(organizationSubscriptions) + .where(eq(organizationSubscriptions.id, subscription.id)); + expect(state).toMatchObject({ + plan: "free", + activeSubscriptionId: null, + }); + expect(row.isEntitlementSource).toBe(false); + }); +}); diff --git a/apps/api/src/billing/entitlements.ts b/apps/api/src/billing/entitlements.ts new file mode 100644 index 0000000..d6e289f --- /dev/null +++ b/apps/api/src/billing/entitlements.ts @@ -0,0 +1,922 @@ +import { and, count, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCheckoutAttempts, + contacts, + organizationPlanStates, + organizationSubscriptions, + organizations, + outboundMessages, + planSendReservations, + planSendUsageBuckets, + teamSendingControls, + teams, +} from "../db/schema"; +import { + resolveEntitlements, + marketingRampDailyLimit, + type OrganizationEntitlements, + type PlanStateLike, + type SubscriptionLike, +} from "./policies"; +import { PlanGateError } from "./errors"; +import { readReputationConfig } from "./reputation-config"; +export { PlanGateError } from "./errors"; + +type Transaction = Parameters[0]>[0]; + +function deploymentMode(): "oss" | "cloud" { + const mode = process.env.SENDLIT_DEPLOYMENT_MODE; + if (mode === "cloud") return "cloud"; + if (mode === "oss") return "oss"; + throw new Error("SENDLIT_DEPLOYMENT_MODE_must_be_oss_or_cloud"); +} + +/** Creates the Free projection row as part of organization creation/backfill. */ +export async function ensureOrganizationPlanState( + tx: Transaction, + organizationId: string, +) { + const [existing] = await tx + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, organizationId)) + .limit(1) + .for("update"); + if (existing) return existing; + const [created] = await tx + .insert(organizationPlanStates) + .values({ organizationId, plan: "free" }) + .onConflictDoNothing({ target: organizationPlanStates.organizationId }) + .returning(); + if (created) return created; + const [raced] = await tx + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, organizationId)) + .limit(1) + .for("update"); + if (!raced) throw new Error("organization_plan_state_unavailable"); + return raced; +} + +export async function getOrganizationEntitlements( + organizationId: string, + now = new Date(), +): Promise { + const [state] = await db + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, organizationId)) + .limit(1); + + let subscription: SubscriptionLike | null = null; + if (state?.activeSubscriptionId) { + const [row] = await db + .select() + .from(organizationSubscriptions) + .where(eq(organizationSubscriptions.id, state.activeSubscriptionId)) + .limit(1); + subscription = row + ? { + plan: row.plan as "pro" | "business", + billingInterval: row.billingInterval as "month" | "year", + status: row.status as SubscriptionLike["status"], + currentPeriodEndsAt: row.currentPeriodEndsAt, + paidThroughAt: row.paidThroughAt, + trialEndsAt: row.trialEndsAt, + graceEndsAt: row.graceEndsAt, + cancelAtPeriodEnd: row.cancelAtPeriodEnd, + } + : null; + } + + const [pending] = await db + .select({ id: billingCheckoutAttempts.id }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.organizationId, organizationId), + inArray(billingCheckoutAttempts.status, ["creating", "open"]), + ), + ) + .limit(1); + + return resolveEntitlements({ + organizationId, + deploymentMode: deploymentMode(), + planState: (state as PlanStateLike | undefined) ?? null, + subscription, + checkoutPending: Boolean(pending), + now, + }); +} + +export async function getOrganizationEntitlementsInTransaction( + tx: Transaction, + organizationId: string, + now = new Date(), +): Promise { + const state = await ensureOrganizationPlanState(tx, organizationId); + const [row] = state.activeSubscriptionId + ? await tx + .select() + .from(organizationSubscriptions) + .where( + eq(organizationSubscriptions.id, state.activeSubscriptionId), + ) + .limit(1) + : []; + const [pending] = await tx + .select({ id: billingCheckoutAttempts.id }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.organizationId, organizationId), + inArray(billingCheckoutAttempts.status, ["creating", "open"]), + ), + ) + .limit(1); + return resolveEntitlements({ + organizationId, + deploymentMode: deploymentMode(), + planState: state as PlanStateLike, + subscription: (row as SubscriptionLike | undefined) ?? null, + checkoutPending: Boolean(pending), + now, + }); +} + +export async function reserveTeamSlot( + tx: Transaction, + organizationId: string, +): Promise { + const [organization] = await tx + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + if (!organization || organization.status !== "active") { + throw new PlanGateError("payment_required", { + reason: "organization_not_active", + organizationId, + }); + } + const entitlements = await getOrganizationEntitlementsInTransaction( + tx, + organizationId, + ); + if (entitlements.teamsLimit === null) return entitlements; + const [{ value }] = await tx + .select({ value: count() }) + .from(teams) + .where( + and( + eq(teams.organizationId, organizationId), + inArray(teams.status, ["active", "sending_suspended"]), + ), + ); + const usage = Number(value); + if (usage >= entitlements.teamsLimit) { + throw new PlanGateError("plan_limit_reached", { + organizationId, + capability: "teams", + limit: entitlements.teamsLimit, + usage, + plan: entitlements.plan, + requiredPlan: entitlements.plan === "free" ? "pro" : "business", + }); + } + return entitlements; +} + +export async function reserveSubscribedContactSlot( + tx: Transaction, + organizationId: string, + teamId: string, +): Promise { + const [team] = await tx + .select({ id: teams.id }) + .from(teams) + .where( + and(eq(teams.id, teamId), eq(teams.organizationId, organizationId)), + ) + .limit(1); + if (!team) throw new Error("team_organization_mismatch"); + const entitlements = await getOrganizationEntitlementsInTransaction( + tx, + organizationId, + ); + if (entitlements.subscribedContactsLimit === null) return entitlements; + const [{ value }] = await tx + .select({ value: count() }) + .from(contacts) + .innerJoin(teams, eq(teams.id, contacts.teamId)) + .where( + and( + eq(teams.organizationId, organizationId), + eq(contacts.subscribed, true), + ), + ); + const usage = Number(value); + if (usage >= entitlements.subscribedContactsLimit) { + throw new PlanGateError("plan_limit_reached", { + organizationId, + capability: "subscribed_contacts", + limit: entitlements.subscribedContactsLimit, + usage, + plan: entitlements.plan, + requiredPlan: entitlements.plan === "free" ? "pro" : "business", + }); + } + return entitlements; +} + +export async function countSubscribedContacts( + tx: Transaction, + organizationId: string, +): Promise { + const [{ value }] = await tx + .select({ value: count() }) + .from(contacts) + .innerJoin(teams, eq(teams.id, contacts.teamId)) + .where( + and( + eq(teams.organizationId, organizationId), + eq(contacts.subscribed, true), + ), + ); + return Number(value); +} + +export async function assertMarketingAllowedForContactUsage( + tx: Transaction, + organizationId: string, + entitlements?: OrganizationEntitlements, +): Promise { + const snapshot = + entitlements ?? + (await getOrganizationEntitlementsInTransaction(tx, organizationId)); + if (snapshot.subscribedContactsLimit === null) return; + const usage = await countSubscribedContacts(tx, organizationId); + if (usage > snapshot.subscribedContactsLimit) { + throw new PlanGateError("plan_limit_reached", { + organizationId, + capability: "subscribed_contacts", + limit: snapshot.subscribedContactsLimit, + usage, + plan: snapshot.plan, + requiredPlan: snapshot.plan === "free" ? "pro" : "business", + }); + } +} + +export function assertCapability( + entitlements: OrganizationEntitlements, + capability: + | "shared_organization_mailbox" + | "provisioning" + | "organization_api_keys", +): void { + const enabled = + capability === "shared_organization_mailbox" + ? entitlements.sharedOrganizationMailbox + : capability === "provisioning" + ? entitlements.provisioning + : entitlements.organizationApiKeys; + if (!enabled) { + throw new PlanGateError("plan_feature_unavailable", { + organizationId: entitlements.organizationId, + capability, + plan: entitlements.plan, + }); + } +} + +async function advanceMarketingRamp( + tx: Transaction, + state: { + id: string; + rampStage: number; + rampCleanStageDays: number; + rampEvaluatedAt: Date | null; + }, + organizationId: string, + now: Date, +): Promise { + const day = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + const evaluatedDay = state.rampEvaluatedAt + ? new Date( + Date.UTC( + state.rampEvaluatedAt.getUTCFullYear(), + state.rampEvaluatedAt.getUTCMonth(), + state.rampEvaluatedAt.getUTCDate(), + ), + ) + : null; + if (evaluatedDay?.getTime() === day.getTime()) return state.rampStage; + const [breach] = await tx + .select({ id: teamSendingControls.id }) + .from(teamSendingControls) + .innerJoin(teams, eq(teams.id, teamSendingControls.teamId)) + .where( + and( + eq(teams.organizationId, organizationId), + sql`${teamSendingControls.status} <> 'normal'`, + ), + ) + .limit(1); + let stage = state.rampStage; + let cleanDays = breach ? 0 : state.rampCleanStageDays + 1; + const requiredDays = stage === 0 ? 3 : stage === 1 ? 4 : 7; + if (!breach && stage < 3 && cleanDays >= requiredDays) { + stage += 1; + cleanDays = 0; + } + await tx + .update(organizationPlanStates) + .set({ + rampStage: stage, + rampCleanStageDays: cleanDays, + rampEvaluatedAt: now, + updatedAt: now, + }) + .where(eq(organizationPlanStates.id, state.id)); + return stage; +} + +/** Atomically reserve plan-governed sends. The reservation is keyed by the + * outbound identity, so retries and concurrent queueing cannot double count. */ +export async function reserveSend( + tx: Transaction, + input: { + organizationId: string; + outboundMessageId: string; + purpose: "marketing" | "transactional"; + amount?: number; + }, +): Promise { + const amount = input.amount ?? 1; + if (!Number.isSafeInteger(amount) || amount <= 0) { + throw new Error("send_reservation_amount_invalid"); + } + const [outbound] = await tx + .select({ teamId: outboundMessages.teamId }) + .from(outboundMessages) + .where(eq(outboundMessages.id, input.outboundMessageId)) + .limit(1) + .for("update"); + const reservationNow = new Date(); + const [existing] = await tx + .select() + .from(planSendReservations) + .where( + eq(planSendReservations.outboundMessageId, input.outboundMessageId), + ) + .limit(1) + .for("update"); + if (existing?.state === "committed") return; + if (existing?.state === "reserved" && existing.expiresAt > reservationNow) + return; + const entitlements = await getOrganizationEntitlementsInTransaction( + tx, + input.organizationId, + ); + if (!entitlements.canSend) { + throw new PlanGateError("sending_paused", { + organizationId: entitlements.organizationId, + plan: entitlements.plan, + graceEndsAt: entitlements.graceEndsAt?.toISOString() ?? null, + }); + } + if (input.purpose === "marketing") { + await assertMarketingAllowedForContactUsage( + tx, + input.organizationId, + entitlements, + ); + } + let controlStatus: string | null = null; + if (entitlements.fairUse && outbound) { + // Lock the team row so concurrent reservations cannot both consume the + // degraded transactional allowance after observing the same count. + await tx + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.id, outbound.teamId)) + .limit(1) + .for("update"); + const [control] = await tx + .select({ status: teamSendingControls.status }) + .from(teamSendingControls) + .where(eq(teamSendingControls.teamId, outbound.teamId)) + .limit(1) + .for("update"); + controlStatus = control?.status ?? null; + if ( + controlStatus === "all_paused" || + (input.purpose === "marketing" && + controlStatus === "marketing_paused") + ) { + throw new PlanGateError("sending_paused", { + organizationId: entitlements.organizationId, + reason: controlStatus, + plan: entitlements.plan, + }); + } + if ( + input.purpose === "transactional" && + controlStatus === "marketing_paused" + ) { + const now = new Date(); + const dayStart = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + ), + ); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + const [[accepted], [reserved]] = await Promise.all([ + tx + .select({ value: count() }) + .from(outboundMessages) + .where( + and( + eq(outboundMessages.teamId, outbound.teamId), + eq(outboundMessages.sourceType, "transactional"), + gte(outboundMessages.acceptedAt, dayStart), + lt(outboundMessages.acceptedAt, dayEnd), + ), + ), + tx + .select({ value: count() }) + .from(outboundMessages) + .innerJoin( + planSendReservations, + eq( + planSendReservations.outboundMessageId, + outboundMessages.id, + ), + ) + .where( + and( + eq(outboundMessages.teamId, outbound.teamId), + eq(outboundMessages.sourceType, "transactional"), + eq(planSendReservations.state, "reserved"), + gt(planSendReservations.expiresAt, now), + gte(planSendReservations.updatedAt, dayStart), + lt(planSendReservations.updatedAt, dayEnd), + ), + ), + ]); + const usage = + Number(accepted?.value ?? 0) + Number(reserved?.value ?? 0); + const limit = readReputationConfig().transactionalDailyLimit; + if (usage >= limit) { + throw new PlanGateError("plan_limit_reached", { + organizationId: entitlements.organizationId, + capability: "transactional_degraded_daily", + limit, + usage, + plan: entitlements.plan, + requiredPlan: entitlements.plan, + }); + } + } + } + if (entitlements.fairUse && input.purpose === "marketing") { + const [rampState] = await tx + .select({ + id: organizationPlanStates.id, + rampStage: organizationPlanStates.rampStage, + rampCleanStageDays: organizationPlanStates.rampCleanStageDays, + rampEvaluatedAt: organizationPlanStates.rampEvaluatedAt, + }) + .from(organizationPlanStates) + .where( + eq(organizationPlanStates.organizationId, input.organizationId), + ) + .limit(1) + .for("update"); + const stage = rampState + ? await advanceMarketingRamp( + tx, + rampState, + input.organizationId, + new Date(), + ) + : 0; + const dailyLimit = marketingRampDailyLimit(stage); + if (dailyLimit !== null) { + const now = new Date(); + const dayStart = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + ), + ); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + const [[accepted], [reserved]] = await Promise.all([ + tx + .select({ value: count() }) + .from(outboundMessages) + .innerJoin(teams, eq(teams.id, outboundMessages.teamId)) + .where( + and( + eq(teams.organizationId, input.organizationId), + eq(outboundMessages.sourceType, "campaign"), + gte(outboundMessages.acceptedAt, dayStart), + lt(outboundMessages.acceptedAt, dayEnd), + ), + ), + tx + .select({ value: count() }) + .from(outboundMessages) + .innerJoin(teams, eq(teams.id, outboundMessages.teamId)) + .innerJoin( + planSendReservations, + eq( + planSendReservations.outboundMessageId, + outboundMessages.id, + ), + ) + .where( + and( + eq(teams.organizationId, input.organizationId), + eq(outboundMessages.sourceType, "campaign"), + eq(planSendReservations.state, "reserved"), + gt(planSendReservations.expiresAt, now), + gte(planSendReservations.updatedAt, dayStart), + lt(planSendReservations.updatedAt, dayEnd), + ), + ), + ]); + const usage = + Number(accepted?.value ?? 0) + Number(reserved?.value ?? 0); + if (usage + amount > dailyLimit) { + throw new PlanGateError("plan_limit_reached", { + organizationId: entitlements.organizationId, + capability: "marketing_daily_ramp", + limit: dailyLimit, + usage, + plan: entitlements.plan, + requiredPlan: entitlements.plan, + }); + } + } + } + // Expired live reservations are released after the outbound/reservation + // and plan-state locks, and before a replacement bucket lock, so retries + // cannot invert send lock order against settlement. + if (existing?.state === "reserved") { + const [oldBucket] = await tx + .select() + .from(planSendUsageBuckets) + .where(eq(planSendUsageBuckets.id, existing.bucketId)) + .limit(1) + .for("update"); + if (!oldBucket) throw new Error("send_usage_bucket_unavailable"); + await tx + .update(planSendUsageBuckets) + .set({ + reserved: Math.max(0, oldBucket.reserved - existing.amount), + updatedAt: reservationNow, + }) + .where(eq(planSendUsageBuckets.id, oldBucket.id)); + await tx + .update(planSendReservations) + .set({ + state: "released", + releasedAt: reservationNow, + updatedAt: reservationNow, + }) + .where(eq(planSendReservations.id, existing.id)); + } + // OSS has no cloud usage accounting. Paid fair-use plans retain a + // reservation even without a monthly cap so degraded transactional sends + // remain atomic and retries stay idempotent. + if (entitlements.monthlySendsLimit === null && !entitlements.fairUse) + return; + const now = reservationNow; + const bucketMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + ); + const [createdBucket] = await tx + .insert(planSendUsageBuckets) + .values({ organizationId: input.organizationId, bucketMonth }) + .onConflictDoNothing() + .returning(); + const bucket = + createdBucket ?? + ( + await tx + .select() + .from(planSendUsageBuckets) + .where( + and( + eq( + planSendUsageBuckets.organizationId, + input.organizationId, + ), + eq(planSendUsageBuckets.bucketMonth, bucketMonth), + ), + ) + .limit(1) + .for("update") + )[0]; + if (!bucket) throw new Error("send_usage_bucket_unavailable"); + // Retries reuse the durable outbound identity. Re-open a released or + // expired reservation inside this transaction so a retry cannot bypass + // the monthly quota by observing an old row and returning early. + if (existing) { + await tx + .update(planSendReservations) + .set({ + bucketId: bucket.id, + amount, + state: "reserved", + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + releasedAt: null, + committedAt: null, + updatedAt: now, + }) + .where(eq(planSendReservations.id, existing.id)); + } else { + await tx.insert(planSendReservations).values({ + organizationId: input.organizationId, + outboundMessageId: input.outboundMessageId, + bucketId: bucket.id, + amount, + state: "reserved", + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + }); + } + if ( + entitlements.monthlySendsLimit !== null && + bucket.committed + bucket.reserved + amount > + entitlements.monthlySendsLimit + ) { + throw new PlanGateError("plan_limit_reached", { + organizationId: entitlements.organizationId, + capability: "monthly_sends", + limit: entitlements.monthlySendsLimit, + usage: bucket.committed + bucket.reserved, + plan: entitlements.plan, + requiredPlan: "pro", + }); + } + await tx + .update(planSendUsageBuckets) + .set({ + reserved: sql`${planSendUsageBuckets.reserved} + ${amount}`, + updatedAt: new Date(), + }) + .where(eq(planSendUsageBuckets.id, bucket.id)); +} + +/** Final transport-boundary check; queued work must not bypass a later + * payment or reputation stop. */ +export async function assertSendAllowedForTeam( + teamId: string, + purpose: "marketing" | "transactional", +): Promise { + const [team] = await db + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) throw new Error("team_not_found"); + const entitlements = await getOrganizationEntitlements(team.organizationId); + if (!entitlements.canSend) { + throw new PlanGateError("sending_paused", { + organizationId: entitlements.organizationId, + plan: entitlements.plan, + graceEndsAt: entitlements.graceEndsAt?.toISOString() ?? null, + }); + } + if (purpose === "marketing") { + await db.transaction(async (tx) => { + await assertMarketingAllowedForContactUsage( + tx, + team.organizationId, + entitlements, + ); + }); + } + const [control] = await db + .select({ status: teamSendingControls.status }) + .from(teamSendingControls) + .where(eq(teamSendingControls.teamId, teamId)) + .limit(1); + if ( + control?.status === "all_paused" || + (purpose === "marketing" && control?.status === "marketing_paused") + ) { + throw new PlanGateError("sending_paused", { + organizationId: entitlements.organizationId, + reason: control.status, + plan: entitlements.plan, + }); + } + if (purpose === "transactional" && control?.status === "marketing_paused") { + const now = new Date(); + const dayStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + const [[accepted], [reserved]] = await Promise.all([ + db + .select({ value: count() }) + .from(outboundMessages) + .where( + and( + eq(outboundMessages.teamId, teamId), + eq(outboundMessages.sourceType, "transactional"), + gte(outboundMessages.acceptedAt, dayStart), + lt(outboundMessages.acceptedAt, dayEnd), + ), + ), + db + .select({ value: count() }) + .from(outboundMessages) + .innerJoin( + planSendReservations, + eq( + planSendReservations.outboundMessageId, + outboundMessages.id, + ), + ) + .where( + and( + eq(outboundMessages.teamId, teamId), + eq(outboundMessages.sourceType, "transactional"), + eq(planSendReservations.state, "reserved"), + gt(planSendReservations.expiresAt, now), + gte(planSendReservations.updatedAt, dayStart), + lt(planSendReservations.updatedAt, dayEnd), + ), + ), + ]); + const usage = + Number(accepted?.value ?? 0) + Number(reserved?.value ?? 0); + const limit = readReputationConfig().transactionalDailyLimit; + if (usage >= limit) { + throw new PlanGateError("plan_limit_reached", { + organizationId: entitlements.organizationId, + capability: "transactional_degraded_daily", + limit, + usage, + plan: entitlements.plan, + requiredPlan: entitlements.plan, + }); + } + } +} + +export async function commitSendReservation( + outboundMessageId: string, +): Promise { + await db.transaction(async (tx) => { + const [reservation] = await tx + .select() + .from(planSendReservations) + .where( + eq(planSendReservations.outboundMessageId, outboundMessageId), + ) + .limit(1) + .for("update"); + if (!reservation || reservation.state !== "reserved") return; + const [bucket] = await tx + .select() + .from(planSendUsageBuckets) + .where(eq(planSendUsageBuckets.id, reservation.bucketId)) + .limit(1) + .for("update"); + if (!bucket) throw new Error("send_usage_bucket_unavailable"); + await tx + .update(planSendReservations) + .set({ + state: "committed", + committedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(planSendReservations.id, reservation.id)); + await tx + .update(planSendUsageBuckets) + .set({ + reserved: Math.max(0, bucket.reserved - reservation.amount), + committed: bucket.committed + reservation.amount, + updatedAt: new Date(), + }) + .where(eq(planSendUsageBuckets.id, bucket.id)); + }); +} + +export async function releaseSendReservation( + outboundMessageId: string, +): Promise { + await db.transaction(async (tx) => { + const [reservation] = await tx + .select() + .from(planSendReservations) + .where( + eq(planSendReservations.outboundMessageId, outboundMessageId), + ) + .limit(1) + .for("update"); + if (!reservation || reservation.state !== "reserved") return; + const [bucket] = await tx + .select() + .from(planSendUsageBuckets) + .where(eq(planSendUsageBuckets.id, reservation.bucketId)) + .limit(1) + .for("update"); + if (!bucket) throw new Error("send_usage_bucket_unavailable"); + await tx + .update(planSendReservations) + .set({ + state: "released", + releasedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(planSendReservations.id, reservation.id)); + await tx + .update(planSendUsageBuckets) + .set({ + reserved: Math.max(0, bucket.reserved - reservation.amount), + updatedAt: new Date(), + }) + .where(eq(planSendUsageBuckets.id, bucket.id)); + }); +} + +/** Reconcile an expired reservation after a worker crash. An outbound row + * already recorded as accepted must consume quota; otherwise the abandoned + * reservation is released. The outbound and reservation are locked together + * so an acceptance update cannot race the decision. */ +export async function settleExpiredSendReservation( + outboundMessageId: string, + now = new Date(), +): Promise { + await db.transaction(async (tx) => { + const [outbound] = await tx + .select({ deliveryStatus: outboundMessages.deliveryStatus }) + .from(outboundMessages) + .where(eq(outboundMessages.id, outboundMessageId)) + .limit(1) + .for("update"); + const [reservation] = await tx + .select() + .from(planSendReservations) + .where( + eq(planSendReservations.outboundMessageId, outboundMessageId), + ) + .limit(1) + .for("update"); + if ( + !reservation || + reservation.state !== "reserved" || + reservation.expiresAt > now + ) { + return; + } + const [bucket] = await tx + .select() + .from(planSendUsageBuckets) + .where(eq(planSendUsageBuckets.id, reservation.bucketId)) + .limit(1) + .for("update"); + if (!bucket) throw new Error("send_usage_bucket_unavailable"); + const accepted = outbound?.deliveryStatus === "accepted"; + await tx + .update(planSendReservations) + .set( + accepted + ? { + state: "committed", + committedAt: now, + updatedAt: now, + } + : { + state: "released", + releasedAt: now, + updatedAt: now, + }, + ) + .where(eq(planSendReservations.id, reservation.id)); + await tx + .update(planSendUsageBuckets) + .set({ + reserved: Math.max(0, bucket.reserved - reservation.amount), + committed: accepted + ? bucket.committed + reservation.amount + : bucket.committed, + updatedAt: now, + }) + .where(eq(planSendUsageBuckets.id, bucket.id)); + }); +} diff --git a/apps/api/src/billing/errors.ts b/apps/api/src/billing/errors.ts new file mode 100644 index 0000000..ae2c7e5 --- /dev/null +++ b/apps/api/src/billing/errors.ts @@ -0,0 +1,58 @@ +export type PlanGateCode = + | "plan_feature_unavailable" + | "plan_limit_reached" + | "payment_required" + | "sending_paused" + | "domain_verification_required"; + +export class PlanGateError extends Error { + readonly status: 402 | 403 | 409; + constructor( + public readonly code: PlanGateCode, + public readonly details: Record = {}, + ) { + const organizationId = details.organizationId; + if ( + typeof organizationId === "string" && + !details.upgradeUrl && + process.env.WEB_CLIENT + ) { + try { + details.upgradeUrl = `${new URL(process.env.WEB_CLIENT).origin}/organizations?tab=plan&organization=${encodeURIComponent(organizationId)}`; + } catch { + // Invalid public URL is reported by startup configuration + // validation; never let it break an otherwise safe denial. + } + } + super(code); + this.name = "PlanGateError"; + this.status = + code === "payment_required" + ? 402 + : code === "plan_limit_reached" + ? 409 + : 403; + } +} + +export function isPlanGateError(error: unknown): error is PlanGateError { + return ( + error instanceof PlanGateError || + Boolean( + error && + typeof error === "object" && + (error as any).name === "PlanGateError", + ) + ); +} + +export function planGateHttp(error: unknown): { + status: 402 | 403 | 409; + body: Record; +} | null { + if (!isPlanGateError(error)) return null; + return { + status: error.status, + body: { error: error.code, ...error.details }, + }; +} diff --git a/apps/api/src/billing/metrics.ts b/apps/api/src/billing/metrics.ts new file mode 100644 index 0000000..720ccbb --- /dev/null +++ b/apps/api/src/billing/metrics.ts @@ -0,0 +1,16 @@ +import logger from "../services/log"; +import { captureEvent } from "../observability/posthog"; + +/** Structured billing telemetry. Never include checkout URLs, portal URLs, + * secrets, or raw provider payloads. */ +export function recordBillingMetric( + event: string, + properties: Record = {}, +): void { + logger.info({ billing_metric: event, ...properties }, event); + captureEvent({ + event, + source: "billing", + properties, + }); +} diff --git a/apps/api/src/billing/notifications.ts b/apps/api/src/billing/notifications.ts new file mode 100644 index 0000000..a0c21d8 --- /dev/null +++ b/apps/api/src/billing/notifications.ts @@ -0,0 +1,155 @@ +import { and, eq } from "drizzle-orm"; +import { createTransport } from "nodemailer"; +import { db } from "../db/client"; +import { + organizationMembers, + organizationSubscriptions, + organizations, + teams, + user, +} from "../db/schema"; +import logger from "../services/log"; +import { recordBillingMetric } from "./metrics"; + +async function platformTransporter() { + if (!process.env.EMAIL_HOST || !process.env.EMAIL_FROM) return null; + return createTransport({ + host: process.env.EMAIL_HOST, + port: Number(process.env.EMAIL_PORT) || 587, + auth: process.env.EMAIL_USER + ? { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS || "", + } + : undefined, + }); +} + +async function recipientsForOrganization( + organizationId: string, +): Promise { + const [subscription] = await db + .select({ + billingManagerUserId: + organizationSubscriptions.billingManagerUserId, + }) + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.organizationId, organizationId), + eq(organizationSubscriptions.isEntitlementSource, true), + ), + ) + .limit(1); + const owners = await db + .select({ email: user.email }) + .from(organizationMembers) + .innerJoin(user, eq(user.id, organizationMembers.userId)) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + eq(organizationMembers.role, "owner"), + ), + ); + const emails = new Set( + owners + .map((row) => row.email) + .filter((email): email is string => Boolean(email)), + ); + if (subscription?.billingManagerUserId) { + const [manager] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, subscription.billingManagerUserId)) + .limit(1); + if (manager?.email) emails.add(manager.email); + } + return [...emails]; +} + +async function sendPlatformMail( + to: string[], + subject: string, + text: string, +): Promise { + if (to.length === 0) return; + const transporter = await platformTransporter(); + if (!transporter) { + logger.warn( + { subject, recipients: to.length }, + "billing notification skipped: platform SMTP is not configured", + ); + return; + } + if (process.env.NODE_ENV !== "production") { + logger.info({ to, subject, text }, "[Dev] billing notification"); + return; + } + await transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: to.join(", "), + subject, + text, + }); +} + +export async function notifyPaymentPastDue( + organizationId: string, + graceEndsAt: Date | null, +): Promise { + const [organization] = await db + .select({ + name: organizations.name, + publicId: organizations.organizationId, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + if (!organization) return; + const grace = graceEndsAt + ? graceEndsAt.toISOString().slice(0, 10) + : "the end of the seven-day grace period"; + const origin = process.env.WEB_CLIENT + ? new URL(process.env.WEB_CLIENT).origin + : ""; + await sendPlatformMail( + await recipientsForOrganization(organizationId), + `Payment past due for ${organization.name}`, + `Payment for ${organization.name} is past due. Paid sending remains available until ${grace}. Update the payment method from Organizations → Plan → Manage billing${origin ? ` (${origin}/organizations?tab=plan&organization=${organization.publicId})` : ""}.`, + ); + recordBillingMetric("billing.notification.past_due", { + organization_public_id: organization.publicId, + }); +} + +export async function notifyReputationChange(input: { + organizationId: string; + teamId: string; + status: string; + reason: string; +}): Promise { + const [organization] = await db + .select({ + name: organizations.name, + publicId: organizations.organizationId, + }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1); + const [team] = await db + .select({ name: teams.name }) + .from(teams) + .where(eq(teams.id, input.teamId)) + .limit(1); + if (!organization || !team) return; + await sendPlatformMail( + await recipientsForOrganization(input.organizationId), + `Sending controls updated for ${team.name}`, + `SendLit applied a fair-use sending control to ${team.name} in ${organization.name}. New status: ${input.status} (${input.reason}). Marketing or all sending may be limited until the list recovers or an operator reviews it.`, + ); + recordBillingMetric("billing.notification.reputation", { + organization_public_id: organization.publicId, + status: input.status, + reason: input.reason, + }); +} diff --git a/apps/api/src/billing/plan-change.test.ts b/apps/api/src/billing/plan-change.test.ts new file mode 100644 index 0000000..38a2166 --- /dev/null +++ b/apps/api/src/billing/plan-change.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const catalogMocks = vi.hoisted(() => ({ + requireActiveCatalog: vi.fn(), + verifyCheckoutOffer: vi.fn(), + getBillingProvider: vi.fn(), +})); + +vi.mock("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); + +vi.mock("./catalog-store", () => ({ + requireActiveCatalog: catalogMocks.requireActiveCatalog, + verifyCheckoutOffer: catalogMocks.verifyCheckoutOffer, +})); + +vi.mock("./provider-registry", () => ({ + getBillingProvider: catalogMocks.getBillingProvider, +})); + +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingPriceEntries, + billingProviderCustomers, + organizationPlanStates, + organizationSubscriptions, +} from "../db/schema"; +import { seedTeamAndContact, truncateAll, type TestDb } from "../test/db"; +import { createOrganizationPlanChange } from "./plan-change"; + +const tdb = db as unknown as TestDb; + +function cloudBillingEnv() { + process.env.SENDLIT_DEPLOYMENT_MODE = "cloud"; + process.env.BILLING_CHECKOUT_PROVIDER = "dodo"; + process.env.BILLING_ENABLED_PROVIDERS = "dodo"; + process.env.BILLING_CATALOG_REVISION = "1"; + process.env.BILLING_CURRENCY = "USD"; + process.env.BILLING_PRO_MONTH_AMOUNT_MINOR = "4900"; + process.env.BILLING_PRO_YEAR_AMOUNT_MINOR = "49000"; + process.env.BILLING_BUSINESS_MONTH_AMOUNT_MINOR = "19900"; + process.env.BILLING_BUSINESS_YEAR_AMOUNT_MINOR = "199000"; + process.env.DODO_PRO_MONTH_PRODUCT_ID = "pdt_pro_month"; + process.env.DODO_PRO_YEAR_PRODUCT_ID = "pdt_pro_year"; + process.env.DODO_BUSINESS_MONTH_PRODUCT_ID = "pdt_business_month"; + process.env.DODO_BUSINESS_YEAR_PRODUCT_ID = "pdt_business_year"; +} + +beforeEach(async () => { + cloudBillingEnv(); + catalogMocks.requireActiveCatalog.mockReset(); + catalogMocks.verifyCheckoutOffer.mockReset(); + catalogMocks.getBillingProvider.mockReset(); + catalogMocks.getBillingProvider.mockReturnValue({ + provider: "dodo", + capabilities: { + planChanges: true, + intervalChanges: true, + portalPlanChanges: false, + portalIntervalChanges: false, + proratedPlanChanges: true, + }, + changeSubscriptionPlan: vi.fn().mockResolvedValue({ + provider: "dodo", + providerPaymentId: null, + paymentUrl: null, + }), + }); + await truncateAll(tdb); +}); + +async function seedActiveSubscription() { + const { account, organization } = await seedTeamAndContact(tdb); + const [currentPrice] = await tdb + .insert(billingPriceEntries) + .values({ + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + currency: "USD", + amountMinor: 4900, + provider: "dodo", + providerProductId: "pdt_pro_month", + }) + .returning(); + const [targetPrice] = await tdb + .insert(billingPriceEntries) + .values({ + catalogKey: "business_month", + plan: "business", + billingInterval: "month", + currency: "USD", + amountMinor: 19900, + provider: "dodo", + providerProductId: "pdt_business_month", + }) + .returning(); + const [customer] = await tdb + .insert(billingProviderCustomers) + .values({ + provider: "dodo", + userId: account.id, + providerCustomerId: `cus_${crypto.randomUUID()}`, + idempotencyKey: `customer:dodo:${account.id}`, + status: "active", + }) + .returning(); + const [subscription] = await tdb + .insert(organizationSubscriptions) + .values({ + organizationId: organization.id, + billingCustomerId: customer.id, + billingManagerUserId: account.id, + provider: "dodo", + providerSubscriptionId: `sub_${crypto.randomUUID()}`, + providerProductId: currentPrice.providerProductId, + billingPriceEntryId: currentPrice.id, + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + status: "active", + isEntitlementSource: true, + }) + .returning(); + await tdb + .update(organizationPlanStates) + .set({ + plan: "pro", + activeSubscriptionId: subscription.id, + }) + .where(eq(organizationPlanStates.organizationId, organization.id)); + catalogMocks.requireActiveCatalog.mockResolvedValue({ + revision: { revision: 1, status: "active" }, + items: [ + { catalogKey: "pro_month", price: currentPrice }, + { catalogKey: "business_month", price: targetPrice }, + ], + }); + catalogMocks.verifyCheckoutOffer.mockResolvedValue(undefined); + return { account, organization, subscription, targetPrice }; +} + +describe("plan-change pointer revalidation", () => { + it("rejects a plan change when the active subscription pointer has been cleared", async () => { + const { account, organization } = await seedActiveSubscription(); + await tdb + .update(organizationPlanStates) + .set({ plan: "free", activeSubscriptionId: null }) + .where(eq(organizationPlanStates.organizationId, organization.id)); + + await expect( + createOrganizationPlanChange({ + organizationId: organization.id, + actorUserId: account.id, + plan: "business", + interval: "month", + catalogRevision: 1, + }), + ).rejects.toMatchObject({ + code: "billing_subscription_required", + status: 402, + }); + }); + + it("rejects a plan change when the locked active subscription is no longer changeable", async () => { + const { account, organization, subscription } = + await seedActiveSubscription(); + await tdb + .update(organizationSubscriptions) + .set({ status: "cancelled", cancelAtPeriodEnd: true }) + .where(eq(organizationSubscriptions.id, subscription.id)); + + await expect( + createOrganizationPlanChange({ + organizationId: organization.id, + actorUserId: account.id, + plan: "business", + interval: "month", + catalogRevision: 1, + }), + ).rejects.toMatchObject({ + code: "billing_subscription_not_changeable", + status: 409, + }); + }); +}); diff --git a/apps/api/src/billing/plan-change.ts b/apps/api/src/billing/plan-change.ts new file mode 100644 index 0000000..892ec92 --- /dev/null +++ b/apps/api/src/billing/plan-change.ts @@ -0,0 +1,460 @@ +import { and, desc, eq, inArray } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { db } from "../db/client"; +import { + billingCatalogRevisionItems, + billingCatalogRevisions, + billingPlanChangeAttempts, + organizationPlanStates, + organizationSubscriptions, + organizations, +} from "../db/schema"; +import { encryptBillingValue, decryptBillingValue } from "./crypto"; +import { readBillingConfig, getBillingOffer } from "./catalog"; +import { requireActiveCatalog, verifyCheckoutOffer } from "./catalog-store"; +import { getBillingProvider } from "./provider-registry"; +import type { BillingProviderError } from "./provider"; + +type PlanChangeCode = + | "billing_catalog_changed" + | "billing_catalog_unavailable" + | "billing_provider_unavailable" + | "billing_owner_required" + | "billing_plan_change_pending" + | "billing_plan_change_not_supported" + | "billing_plan_change_same_plan" + | "billing_subscription_required" + | "billing_subscription_not_changeable"; + +export class BillingPlanChangeError extends Error { + constructor( + public readonly code: PlanChangeCode, + public readonly status: 402 | 403 | 409 | 503, + public readonly details: Record = {}, + ) { + super(code); + this.name = "BillingPlanChangeError"; + } +} + +type PlanChangeRow = typeof billingPlanChangeAttempts.$inferSelect; + +function responseFor(row: PlanChangeRow, includePaymentUrl: boolean) { + let paymentUrl: string | null = null; + if (includePaymentUrl && row.paymentUrlEncrypted) { + try { + paymentUrl = decryptBillingValue(row.paymentUrlEncrypted); + } catch { + // A corrupted/expired payment link is never surfaced. The + // provider webhook or a fresh request remains authoritative. + } + } + return { + changeId: row.changeId, + status: (row.status === "creating" ? "pending" : row.status) as + "pending" | "succeeded" | "failed" | "conflicted", + targetPlan: row.targetPlan as "pro" | "business", + targetInterval: row.targetInterval as "month" | "year", + effectiveAt: row.effectiveAt as "immediately" | "next_billing_date", + paymentUrl, + completedAt: row.completedAt?.toISOString() ?? null, + }; +} + +function planRank(plan: "pro" | "business"): number { + return plan === "business" ? 2 : 1; +} + +function defaultPolicy( + currentPlan: "pro" | "business", + currentInterval: "month" | "year", + targetPlan: "pro" | "business", + targetInterval: "month" | "year", +) { + const upgrade = + planRank(targetPlan) > planRank(currentPlan) || + (targetPlan === currentPlan && + currentInterval === "month" && + targetInterval === "year"); + return { + effectiveAt: upgrade + ? ("immediately" as const) + : ("next_billing_date" as const), + prorationMode: upgrade + ? ("prorated_immediately" as const) + : ("do_not_bill" as const), + }; +} + +function providerErrorCode(error: unknown): string { + return error && typeof error === "object" && "code" in error + ? String((error as BillingProviderError).code) + : "provider_error"; +} + +async function currentCatalogRevision( + priceEntryId: string, + fallback: number, +): Promise { + const [row] = await db + .select({ revision: billingCatalogRevisions.revision }) + .from(billingCatalogRevisionItems) + .innerJoin( + billingCatalogRevisions, + eq( + billingCatalogRevisions.id, + billingCatalogRevisionItems.catalogRevisionId, + ), + ) + .where( + eq(billingCatalogRevisionItems.billingPriceEntryId, priceEntryId), + ) + .orderBy(desc(billingCatalogRevisions.revision)) + .limit(1); + return row?.revision ?? fallback; +} + +export async function createOrganizationPlanChange(input: { + organizationId: string; + actorUserId: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + idempotencyKey?: string; +}) { + let config: ReturnType; + try { + config = readBillingConfig(); + } catch { + throw new BillingPlanChangeError("billing_catalog_unavailable", 503); + } + if (config.deploymentMode !== "cloud" || !config.catalogRevision) { + throw new BillingPlanChangeError("billing_provider_unavailable", 503); + } + if (input.catalogRevision !== config.catalogRevision) { + throw new BillingPlanChangeError("billing_catalog_changed", 409, { + catalogRevision: config.catalogRevision, + currency: config.currency, + offers: config.offers.map( + ({ providerProductId: _providerProductId, ...offer }) => offer, + ), + checkoutAvailable: true, + }); + } + const offer = getBillingOffer(config, input.plan, input.interval); + if (!offer) + throw new BillingPlanChangeError("billing_catalog_unavailable", 503); + + let provider; + let catalog; + try { + provider = getBillingProvider(config.checkoutProvider ?? undefined); + catalog = await requireActiveCatalog(config, provider); + await verifyCheckoutOffer(config, provider, offer); + } catch (error) { + if ( + error instanceof Error && + error.message === "billing_catalog_changed" + ) { + throw new BillingPlanChangeError("billing_catalog_changed", 409, { + catalogRevision: config.catalogRevision, + currency: config.currency, + offers: config.offers.map( + ({ providerProductId: _providerProductId, ...offer }) => + offer, + ), + checkoutAvailable: false, + }); + } + throw new BillingPlanChangeError("billing_catalog_unavailable", 503); + } + const targetPrice = catalog.items.find( + (row) => row.catalogKey === offer.catalogKey, + )?.price; + if (!targetPrice) + throw new BillingPlanChangeError("billing_catalog_unavailable", 503); + + const idempotencyKey = `plan-change:${input.organizationId}:${input.idempotencyKey?.trim() || randomUUID()}`; + const now = new Date(); + const pending = await db.transaction(async (tx) => { + const [observedState] = await tx + .select() + .from(organizationPlanStates) + .where( + eq(organizationPlanStates.organizationId, input.organizationId), + ) + .limit(1); + if (!observedState?.activeSubscriptionId) { + throw new BillingPlanChangeError( + "billing_subscription_required", + 402, + ); + } + const [subscription] = await tx + .select() + .from(organizationSubscriptions) + .where( + and( + eq( + organizationSubscriptions.id, + observedState.activeSubscriptionId, + ), + eq( + organizationSubscriptions.organizationId, + input.organizationId, + ), + ), + ) + .limit(1) + .for("update"); + if (!subscription) + throw new BillingPlanChangeError( + "billing_subscription_required", + 402, + ); + const [organization] = await tx + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1) + .for("update"); + if (!organization || organization.status !== "active") { + throw new BillingPlanChangeError( + "billing_subscription_not_changeable", + 409, + ); + } + const [state] = await tx + .select() + .from(organizationPlanStates) + .where( + eq(organizationPlanStates.organizationId, input.organizationId), + ) + .limit(1) + .for("update"); + if (state?.activeSubscriptionId !== subscription.id) { + throw new BillingPlanChangeError( + "billing_subscription_not_changeable", + 409, + ); + } + if (subscription.billingManagerUserId !== input.actorUserId) { + throw new BillingPlanChangeError("billing_owner_required", 403); + } + + // Idempotency is checked before validating the current projection: a + // successful request may already have moved the subscription to the + // target product by the time the client retries. + const [existingByKey] = await tx + .select() + .from(billingPlanChangeAttempts) + .where(eq(billingPlanChangeAttempts.idempotencyKey, idempotencyKey)) + .limit(1) + .for("update"); + if (existingByKey) { + if ( + existingByKey.organizationId !== input.organizationId || + existingByKey.targetPlan !== input.plan || + existingByKey.targetInterval !== input.interval + ) { + throw new BillingPlanChangeError( + "billing_plan_change_pending", + 409, + ); + } + return { row: existingByKey, subscription }; + } + + if (!["trialing", "active"].includes(subscription.status)) { + throw new BillingPlanChangeError( + "billing_subscription_not_changeable", + 409, + ); + } + const currentPlan = subscription.plan as "pro" | "business"; + const currentInterval = subscription.billingInterval as + "month" | "year"; + + if (currentPlan === input.plan && currentInterval === input.interval) { + throw new BillingPlanChangeError( + "billing_plan_change_same_plan", + 409, + ); + } + if ( + !provider.capabilities.planChanges || + (currentInterval !== input.interval && + !provider.capabilities.intervalChanges) + ) { + throw new BillingPlanChangeError( + "billing_plan_change_not_supported", + 409, + ); + } + + const [existing] = await tx + .select() + .from(billingPlanChangeAttempts) + .where( + and( + eq( + billingPlanChangeAttempts.organizationId, + input.organizationId, + ), + inArray(billingPlanChangeAttempts.status, [ + "creating", + "pending", + ]), + ), + ) + .limit(1) + .for("update"); + if (existing) + throw new BillingPlanChangeError( + "billing_plan_change_pending", + 409, + { changeId: existing.changeId }, + ); + + const policy = defaultPolicy( + currentPlan, + currentInterval, + input.plan, + input.interval, + ); + if ( + policy.prorationMode === "prorated_immediately" && + !provider.capabilities.proratedPlanChanges + ) { + throw new BillingPlanChangeError( + "billing_plan_change_not_supported", + 409, + ); + } + const [row] = await tx + .insert(billingPlanChangeAttempts) + .values({ + organizationId: input.organizationId, + subscriptionId: subscription.id, + actorUserId: input.actorUserId, + provider: subscription.provider, + idempotencyKey, + currentCatalogRevision: await currentCatalogRevision( + subscription.billingPriceEntryId, + config.catalogRevision!, + ), + currentBillingPriceEntryId: subscription.billingPriceEntryId, + currentPlan, + currentInterval, + targetCatalogRevision: config.catalogRevision!, + targetBillingPriceEntryId: targetPrice.id, + targetPlan: input.plan, + targetInterval: input.interval, + effectiveAt: policy.effectiveAt, + prorationMode: policy.prorationMode, + status: "creating", + requestedAt: now, + }) + .returning(); + if (!row) + throw new BillingPlanChangeError( + "billing_provider_unavailable", + 503, + ); + return { row, subscription }; + }); + + if (pending.row.status !== "creating") { + return responseFor( + pending.row, + pending.row.actorUserId === input.actorUserId, + ); + } + + let result; + try { + result = await provider.changeSubscriptionPlan({ + providerSubscriptionId: pending.subscription.providerSubscriptionId, + targetProviderProductId: offer.providerProductId, + effectiveAt: pending.row.effectiveAt as + "immediately" | "next_billing_date", + prorationMode: pending.row.prorationMode as + "prorated_immediately" | "do_not_bill", + idempotencyKey: pending.row.idempotencyKey, + }); + } catch (error) { + const code = providerErrorCode(error); + const ambiguous = code === "unavailable" || code === "rate_limited"; + const [updated] = await db + .update(billingPlanChangeAttempts) + .set({ + status: ambiguous ? "pending" : "failed", + lastError: code.slice(0, 120), + completedAt: ambiguous ? null : new Date(), + updatedAt: new Date(), + }) + .where(eq(billingPlanChangeAttempts.id, pending.row.id)) + .returning(); + if (ambiguous) { + // The request may have reached the provider even though the HTTP + // response was lost. Return the durable pending attempt and let + // reconciliation retry with the same idempotency key. + return responseFor(updated ?? pending.row, true); + } + throw new BillingPlanChangeError( + "billing_plan_change_not_supported", + 409, + { + reason: code, + }, + ); + } + + try { + const [updated] = await db + .update(billingPlanChangeAttempts) + .set({ + status: "pending", + providerPaymentId: result.providerPaymentId, + paymentUrlEncrypted: result.paymentUrl + ? encryptBillingValue(result.paymentUrl) + : null, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(billingPlanChangeAttempts.id, pending.row.id)) + .returning(); + if (!updated) throw new Error("billing_plan_change_update_failed"); + return responseFor(updated, true); + } catch { + // The provider mutation may already have succeeded. Leave the local + // row non-terminal so reconciliation can persist the result and the + // signed webhook can still project entitlements. + throw new BillingPlanChangeError("billing_provider_unavailable", 503, { + changeId: pending.row.changeId, + pending: true, + }); + } +} + +export async function getOrganizationPlanChange(input: { + organizationId: string; + changeId: string; + userId: string; +}) { + const [row] = await db + .select() + .from(billingPlanChangeAttempts) + .where( + and( + eq( + billingPlanChangeAttempts.organizationId, + input.organizationId, + ), + eq(billingPlanChangeAttempts.changeId, input.changeId), + ), + ) + .limit(1); + if (!row) return null; + return responseFor(row, row.actorUserId === input.userId); +} diff --git a/apps/api/src/billing/policies.test.ts b/apps/api/src/billing/policies.test.ts new file mode 100644 index 0000000..8f6900a --- /dev/null +++ b/apps/api/src/billing/policies.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { resolveEntitlements } from "./policies"; + +const state = { + plan: "business" as const, + teamsLimitOverride: null, + contactsLimitOverride: null, +}; + +describe("billing entitlement projection", () => { + it("keeps a cancelled subscription paid only through its verified deadline", () => { + const now = new Date("2026-08-29T00:00:00.000Z"); + const base = { + plan: "business" as const, + billingInterval: "month" as const, + currentPeriodEndsAt: new Date("2026-09-29T00:00:00.000Z"), + trialEndsAt: null, + graceEndsAt: null, + cancelAtPeriodEnd: true, + }; + expect( + resolveEntitlements({ + organizationId: "org", + deploymentMode: "cloud", + planState: state, + subscription: { + ...base, + status: "cancelled", + paidThroughAt: new Date("2026-09-29T00:00:00.000Z"), + }, + now, + }).plan, + ).toBe("business"); + expect( + resolveEntitlements({ + organizationId: "org", + deploymentMode: "cloud", + planState: state, + subscription: { + ...base, + status: "cancelled", + paidThroughAt: new Date("2026-08-28T00:00:00.000Z"), + }, + now, + }).paymentStatus, + ).toBe("expired"); + }); + + it("removes paid access immediately when cancellation is not scheduled for period end", () => { + const entitlements = resolveEntitlements({ + organizationId: "org", + deploymentMode: "cloud", + planState: state, + subscription: { + plan: "business", + billingInterval: "month", + status: "cancelled", + currentPeriodEndsAt: new Date("2026-09-29T00:00:00.000Z"), + paidThroughAt: new Date("2026-09-29T00:00:00.000Z"), + trialEndsAt: null, + graceEndsAt: null, + cancelAtPeriodEnd: false, + }, + now: new Date("2026-08-29T00:00:00.000Z"), + }); + + expect(entitlements.plan).toBe("free"); + expect(entitlements.paymentStatus).toBe("expired"); + expect(entitlements.teamsLimit).toBe(1); + }); + + it("resolves to Free when there is no entitlement-bearing subscription", () => { + const entitlements = resolveEntitlements({ + organizationId: "org", + deploymentMode: "cloud", + planState: state, + subscription: null, + }); + expect(entitlements.plan).toBe("free"); + expect(entitlements.teamsLimit).toBe(1); + }); +}); diff --git a/apps/api/src/billing/policies.ts b/apps/api/src/billing/policies.ts new file mode 100644 index 0000000..5cfb600 --- /dev/null +++ b/apps/api/src/billing/policies.ts @@ -0,0 +1,235 @@ +export type PlanId = "oss" | "free" | "pro" | "business"; + +export type BillingInterval = "month" | "year"; + +export type PaymentStatus = + | "free" + | "checkout_pending" + | "trialing" + | "active" + | "past_due" + | "cancel_at_period_end" + | "cancelled" + | "expired"; + +export type PlanPolicy = { + teamsLimit: number | null; + subscribedContactsLimit: number | null; + monthlySendsLimit: number | null; + sharedOrganizationMailbox: boolean; + provisioning: boolean; + organizationApiKeys: boolean; + marketingBranding: boolean; + fairUse: boolean; +}; + +/** Product capabilities live here; paid amounts live in catalog configuration. */ +export const planPolicies: Record = { + oss: { + teamsLimit: null, + subscribedContactsLimit: null, + monthlySendsLimit: null, + sharedOrganizationMailbox: true, + provisioning: true, + organizationApiKeys: true, + marketingBranding: false, + fairUse: false, + }, + free: { + teamsLimit: 1, + subscribedContactsLimit: 1_000, + monthlySendsLimit: 3_000, + sharedOrganizationMailbox: false, + provisioning: false, + organizationApiKeys: false, + marketingBranding: true, + fairUse: false, + }, + pro: { + teamsLimit: 5, + subscribedContactsLimit: 10_000, + monthlySendsLimit: null, + sharedOrganizationMailbox: true, + provisioning: false, + organizationApiKeys: false, + marketingBranding: false, + fairUse: true, + }, + business: { + teamsLimit: 25, + subscribedContactsLimit: null, + monthlySendsLimit: null, + sharedOrganizationMailbox: true, + provisioning: true, + organizationApiKeys: true, + marketingBranding: false, + fairUse: true, + }, +}; + +/** Paid marketing ramp defaults are operational policy knobs, not code-level + * price constants. They are read when a reservation is made so a deployment + * can tune ramp stages without rebuilding the API. */ +function positiveRampInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + if (!/^\d+$/.test(raw)) throw new Error(`${name}_invalid`); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) { + throw new Error(`${name}_invalid`); + } + return value; +} + +export function marketingRampDailyLimit(stage: number): number | null { + if (stage <= 0) return positiveRampInt("BILLING_RAMP_DAYS_0_2_LIMIT", 200); + if (stage === 1) + return positiveRampInt("BILLING_RAMP_DAYS_3_6_LIMIT", 1_000); + if (stage === 2) + return positiveRampInt("BILLING_RAMP_DAYS_7_13_LIMIT", 10_000); + return null; +} + +export type SubscriptionLike = { + plan: "pro" | "business"; + billingInterval: BillingInterval; + status: + | "pending" + | "trialing" + | "active" + | "past_due" + | "cancelled" + | "expired"; + currentPeriodEndsAt: Date | null; + paidThroughAt: Date | null; + trialEndsAt: Date | null; + graceEndsAt: Date | null; + cancelAtPeriodEnd: boolean; +}; + +export type PlanStateLike = { + plan: "free" | "pro" | "business"; + teamsLimitOverride: number | null; + contactsLimitOverride: number | null; +}; + +export type OrganizationEntitlements = { + organizationId: string; + plan: PlanId; + interval: BillingInterval | null; + paymentStatus: PaymentStatus; + teamsLimit: number | null; + subscribedContactsLimit: number | null; + monthlySendsLimit: number | null; + sharedOrganizationMailbox: boolean; + provisioning: boolean; + organizationApiKeys: boolean; + marketingBranding: boolean; + fairUse: boolean; + canSend: boolean; + graceEndsAt: Date | null; +}; + +export function resolveEntitlements({ + organizationId, + deploymentMode, + planState, + subscription, + checkoutPending = false, + now = new Date(), +}: { + organizationId: string; + deploymentMode: "oss" | "cloud"; + planState: PlanStateLike | null; + subscription?: SubscriptionLike | null; + checkoutPending?: boolean; + now?: Date; +}): OrganizationEntitlements { + if (deploymentMode === "oss") { + return { + organizationId, + plan: "oss", + interval: null, + paymentStatus: "free", + ...planPolicies.oss, + canSend: true, + graceEndsAt: null, + }; + } + + const plan = planState?.plan ?? "free"; + let effectivePlan: PlanId = plan; + let paymentStatus: PaymentStatus = checkoutPending + ? "checkout_pending" + : "free"; + let interval: BillingInterval | null = null; + let graceEndsAt: Date | null = null; + let canSend = true; + + if (subscription) { + interval = subscription.billingInterval; + const paidThrough = subscription.paidThroughAt; + const hasFuturePaidThrough = Boolean( + paidThrough && paidThrough.getTime() > now.getTime(), + ); + if ( + subscription.status === "cancelled" && + (!subscription.cancelAtPeriodEnd || !hasFuturePaidThrough) + ) { + effectivePlan = "free"; + interval = null; + paymentStatus = "expired"; + } else if (subscription.status === "expired") { + effectivePlan = "free"; + interval = null; + paymentStatus = "expired"; + } else if (subscription.status === "pending") { + effectivePlan = "free"; + interval = null; + paymentStatus = "checkout_pending"; + } else { + effectivePlan = subscription.plan; + paymentStatus = + subscription.status === "trialing" + ? "trialing" + : subscription.status === "past_due" + ? "past_due" + : subscription.cancelAtPeriodEnd + ? "cancel_at_period_end" + : subscription.status === "cancelled" + ? "cancelled" + : "active"; + graceEndsAt = subscription.graceEndsAt; + if ( + subscription.status === "past_due" && + graceEndsAt && + graceEndsAt.getTime() <= now.getTime() + ) { + canSend = false; + } + } + } else { + effectivePlan = "free"; + interval = null; + } + + const effectiveBase = planPolicies[effectivePlan]; + return { + organizationId, + plan: effectivePlan, + interval, + paymentStatus, + teamsLimit: planState?.teamsLimitOverride ?? effectiveBase.teamsLimit, + subscribedContactsLimit: + planState?.contactsLimitOverride ?? + effectiveBase.subscribedContactsLimit, + monthlySendsLimit: effectiveBase.monthlySendsLimit, + sharedOrganizationMailbox: effectiveBase.sharedOrganizationMailbox, + provisioning: effectiveBase.provisioning, + organizationApiKeys: effectiveBase.organizationApiKeys, + marketingBranding: effectiveBase.marketingBranding, + fairUse: effectiveBase.fairUse, + canSend, + graceEndsAt, + }; +} diff --git a/apps/api/src/billing/portal.ts b/apps/api/src/billing/portal.ts new file mode 100644 index 0000000..f21f656 --- /dev/null +++ b/apps/api/src/billing/portal.ts @@ -0,0 +1,81 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingProviderCustomers, + organizationPlanStates, + organizationSubscriptions, + organizations, +} from "../db/schema"; +import { readBillingConfig } from "./catalog"; +import { getBillingProvider } from "./provider-registry"; +import { BillingCheckoutError } from "./checkout"; + +export async function createOrganizationPortal(input: { + organizationId: string; + userId: string; +}) { + let config: ReturnType; + try { + config = readBillingConfig(); + } catch { + throw new BillingCheckoutError("billing_provider_unavailable", 503); + } + if (config.deploymentMode !== "cloud") + throw new BillingCheckoutError("billing_provider_unavailable", 503); + const [organization] = await db + .select({ publicId: organizations.organizationId }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1); + if (!organization) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + const [state] = await db + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, input.organizationId)) + .limit(1); + if (!state?.activeSubscriptionId) + throw new BillingCheckoutError("payment_required", 402); + const [subscription] = await db + .select({ + customerId: organizationSubscriptions.billingCustomerId, + manager: organizationSubscriptions.billingManagerUserId, + provider: organizationSubscriptions.provider, + }) + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.id, state.activeSubscriptionId), + eq( + organizationSubscriptions.organizationId, + input.organizationId, + ), + ), + ) + .limit(1); + if (!subscription || subscription.manager !== input.userId) + throw new BillingCheckoutError("billing_owner_required", 403); + const [customer] = await db + .select({ + providerCustomerId: billingProviderCustomers.providerCustomerId, + }) + .from(billingProviderCustomers) + .where(eq(billingProviderCustomers.id, subscription.customerId)) + .limit(1); + if (!customer?.providerCustomerId) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + try { + const provider = getBillingProvider(subscription.provider); + const webClient = process.env.WEB_CLIENT; + if (!webClient) + throw new BillingCheckoutError("billing_provider_unavailable", 503); + const portal = await provider.createPortalSession({ + customerId: customer.providerCustomerId, + returnUrl: `${new URL(webClient).origin}/organizations?tab=plan&organization=${encodeURIComponent(organization.publicId)}`, + }); + return { portalUrl: portal.portalUrl }; + } catch (error) { + if (error instanceof BillingCheckoutError) throw error; + throw new BillingCheckoutError("billing_provider_unavailable", 503); + } +} diff --git a/apps/api/src/billing/provider-contract.ts b/apps/api/src/billing/provider-contract.ts new file mode 100644 index 0000000..d83764b --- /dev/null +++ b/apps/api/src/billing/provider-contract.ts @@ -0,0 +1,160 @@ +import { expect } from "vitest"; +import { BillingProviderError, type BillingProviderAdapter } from "./provider"; +import { + FakeBillingProvider, + FAKE_BILLING_WEBHOOK_KEY, +} from "./providers/fake"; + +/** Shared adapter contract. Every billing provider, including the in-memory + * fake, must satisfy these canonical behaviors. */ +export async function runBillingProviderContract( + adapter: BillingProviderAdapter, + helpers: { + signWebhook: FakeBillingProvider["signWebhook"]; + simulatePayment: FakeBillingProvider["simulatePayment"]; + productId: string; + otherProductId: string; + }, +): Promise { + expect(adapter.capabilities.portalPlanChanges).toBe(false); + expect(adapter.capabilities.portalIntervalChanges).toBe(false); + + const product = await adapter.retrieveProduct(helpers.productId); + expect(product.provider).toBe(adapter.provider); + expect(product.providerProductId).toBe(helpers.productId); + expect(product.amountMinor).toBeGreaterThan(0); + expect(product.interval === "month" || product.interval === "year").toBe( + true, + ); + + await expect(adapter.retrieveProduct("pdt_unknown")).rejects.toBeInstanceOf( + BillingProviderError, + ); + + const customer = await adapter.createCustomer({ + email: "payer@example.com", + name: "Payer", + idempotencyKey: "customer:fake:1", + }); + const customerAgain = await adapter.createCustomer({ + email: "payer@example.com", + name: "Payer", + idempotencyKey: "customer:fake:1", + }); + expect(customerAgain.providerCustomerId).toBe(customer.providerCustomerId); + + const checkout = await adapter.createCheckout({ + productId: helpers.productId, + currency: product.currency, + customerId: customer.providerCustomerId, + payerEmail: "payer@example.com", + returnUrl: "https://app.test/organizations?tab=plan", + attemptId: "bca_attempt_1", + catalogKey: "pro_month", + trialDays: 14, + idempotencyKey: "checkout:fake:1", + }); + expect(checkout.checkoutUrl.startsWith("http")).toBe(true); + const checkoutAgain = await adapter.createCheckout({ + productId: helpers.productId, + currency: product.currency, + customerId: customer.providerCustomerId, + payerEmail: "payer@example.com", + returnUrl: "https://app.test/organizations?tab=plan", + attemptId: "bca_attempt_1", + catalogKey: "pro_month", + trialDays: 14, + idempotencyKey: "checkout:fake:1", + }); + expect(checkoutAgain.providerCheckoutSessionId).toBe( + checkout.providerCheckoutSessionId, + ); + + const paid = await helpers.simulatePayment( + checkout.providerCheckoutSessionId, + ); + expect(paid.status === "trialing" || paid.status === "active").toBe(true); + expect(paid.metadata.sendlitCheckoutAttemptId).toBe("bca_attempt_1"); + const retrieved = await adapter.retrieveSubscription( + paid.providerSubscriptionId, + ); + expect(retrieved.providerProductId).toBe(helpers.productId); + expect(retrieved.providerCustomerId).toBe(customer.providerCustomerId); + + const changed = await adapter.changeSubscriptionPlan({ + providerSubscriptionId: paid.providerSubscriptionId, + targetProviderProductId: helpers.otherProductId, + effectiveAt: "immediately", + prorationMode: "prorated_immediately", + idempotencyKey: "plan-change:fake:1", + }); + expect(changed.provider).toBe(adapter.provider); + const afterChange = await adapter.retrieveSubscription( + paid.providerSubscriptionId, + ); + expect(afterChange.providerProductId).toBe(helpers.otherProductId); + + const portal = await adapter.createPortalSession({ + customerId: customer.providerCustomerId, + returnUrl: "https://app.test/organizations?tab=plan", + }); + expect(portal.portalUrl.startsWith("http")).toBe(true); + + await adapter.cancelSubscription( + paid.providerSubscriptionId, + "cancel:fake:1", + ); + const cancelled = await adapter.retrieveSubscription( + paid.providerSubscriptionId, + ); + expect(cancelled.status).toBe("cancelled"); + + const signed = helpers.signWebhook( + JSON.stringify({ + type: "subscription.updated", + data: { + subscription_id: paid.providerSubscriptionId, + customer_id: customer.providerCustomerId, + product_id: helpers.otherProductId, + status: "cancelled", + }, + }), + ); + const event = await adapter.parseWebhook(signed); + expect(event.provider).toBe(adapter.provider); + expect(event.subscriptionId).toBe(paid.providerSubscriptionId); + expect(event.eventType).toBe("subscription.updated"); + + await expect( + adapter.parseWebhook({ + body: signed.body, + headers: { ...signed.headers, "webhook-signature": "v1,deadbeef" }, + }), + ).rejects.toThrow(/webhook_signature_invalid/); + + const stale = helpers.signWebhook( + JSON.stringify({ type: "subscription.updated", data: {} }), + "evt_stale", + new Date(Date.now() - 10 * 60 * 1000), + ); + await expect(adapter.parseWebhook(stale)).rejects.toThrow( + /webhook_timestamp_stale/, + ); +} + +export function createContractFake(): { + adapter: FakeBillingProvider; + helpers: Parameters[1]; +} { + const adapter = new FakeBillingProvider(FAKE_BILLING_WEBHOOK_KEY); + adapter.seedDefaultCatalog(); + return { + adapter, + helpers: { + signWebhook: adapter.signWebhook.bind(adapter), + simulatePayment: adapter.simulatePayment.bind(adapter), + productId: "pdt_pro_month", + otherProductId: "pdt_business_month", + }, + }; +} diff --git a/apps/api/src/billing/provider-registry.ts b/apps/api/src/billing/provider-registry.ts new file mode 100644 index 0000000..094007d --- /dev/null +++ b/apps/api/src/billing/provider-registry.ts @@ -0,0 +1,41 @@ +import { readBillingConfig } from "./catalog"; +import type { BillingProviderAdapter, BillingProviderId } from "./provider"; +import { DodoBillingProvider } from "./providers/dodo"; +import { FakeBillingProvider } from "./providers/fake"; + +/** Lazily-created adapters keep provider credentials out of module import time. */ +const instances = new Map(); + +export function getBillingProvider( + provider?: BillingProviderId, +): BillingProviderAdapter { + const config = readBillingConfig(); + const selected = provider ?? config.checkoutProvider; + if (!selected) throw new Error("billing_provider_not_configured"); + if (!config.enabledProviders.includes(selected)) { + throw new Error("billing_provider_not_enabled"); + } + const existing = instances.get(selected); + if (existing) return existing; + let adapter: BillingProviderAdapter; + switch (selected) { + case "dodo": + adapter = new DodoBillingProvider(); + break; + case "fake": { + if (process.env.NODE_ENV === "production") { + throw new Error( + "fake_billing_provider_not_allowed_in_production", + ); + } + const fake = new FakeBillingProvider(); + fake.seedDefaultCatalog(); + adapter = fake; + break; + } + default: + throw new Error(`unsupported_billing_provider:${selected}`); + } + instances.set(selected, adapter); + return adapter; +} diff --git a/apps/api/src/billing/provider.ts b/apps/api/src/billing/provider.ts new file mode 100644 index 0000000..a52d80a --- /dev/null +++ b/apps/api/src/billing/provider.ts @@ -0,0 +1,147 @@ +/** + * Provider-neutral billing contract. Nothing outside `providers/*` should + * import a payment provider SDK or inspect its payloads/status names. + */ +export type BillingProviderId = "dodo" | (string & {}); + +export type BillingProductSnapshot = { + provider: BillingProviderId; + providerProductId: string; + currency: string; + amountMinor: number; + interval: "month" | "year"; +}; + +export type BillingCustomer = { + provider: BillingProviderId; + providerCustomerId: string; +}; + +export type Checkout = { + provider: BillingProviderId; + providerCheckoutSessionId: string; + checkoutUrl: string; +}; + +export type PortalSession = { + provider: BillingProviderId; + portalUrl: string; +}; + +export type SubscriptionPlanChangeInput = { + providerSubscriptionId: string; + targetProviderProductId: string; + effectiveAt: "immediately" | "next_billing_date"; + prorationMode: "prorated_immediately" | "do_not_bill"; + idempotencyKey: string; +}; + +export type SubscriptionPlanChangeResult = { + provider: BillingProviderId; + providerPaymentId: string | null; + paymentUrl: string | null; +}; + +export type SubscriptionSnapshot = { + provider: BillingProviderId; + providerCustomerId: string; + providerSubscriptionId: string; + providerProductId: string; + status: + | "pending" + | "trialing" + | "active" + | "past_due" + | "cancelled" + | "expired"; + currentPeriodStartsAt: Date | null; + currentPeriodEndsAt: Date | null; + paidThroughAt: Date | null; + trialEndsAt: Date | null; + cancelAtPeriodEnd: boolean; + occurredAt: Date; + metadata: { + sendlitCheckoutAttemptId?: string; + catalogKey?: string; + }; +}; + +export type RawWebhookRequest = { + body: string; + headers: Record; +}; + +export type CanonicalBillingEvent = { + provider: BillingProviderId; + providerEventId: string; + eventType: string; + occurredAt: Date; + subscriptionId?: string; + snapshot?: SubscriptionSnapshot; + rawPayload: unknown; +}; + +export type BillingProviderErrorCode = + | "invalid" + | "unauthorized" + | "conflict" + | "rate_limited" + | "unavailable" + | "misconfigured"; + +export class BillingProviderError extends Error { + constructor( + public readonly code: BillingProviderErrorCode, + message: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = "BillingProviderError"; + } +} + +/** Provider SDK messages can contain request IDs, URLs, or response bodies. + * Persist only a stable category in billing rows/logs. */ +export function providerErrorSummary(error: unknown): string { + if (error instanceof BillingProviderError) return `provider_${error.code}`; + return "provider_error"; +} + +export interface BillingProviderAdapter { + readonly provider: BillingProviderId; + readonly capabilities: { + planChanges: boolean; + intervalChanges: boolean; + portalPlanChanges: boolean; + portalIntervalChanges: boolean; + proratedPlanChanges: boolean; + }; + createCustomer(input: { + email: string; + name?: string | null; + idempotencyKey: string; + }): Promise; + createCheckout(input: { + productId: string; + currency: string; + customerId: string; + payerEmail: string; + returnUrl: string; + cancelUrl?: string; + attemptId: string; + catalogKey: string; + trialDays: number; + idempotencyKey: string; + }): Promise; + createPortalSession(input: { + customerId: string; + returnUrl: string; + }): Promise; + changeSubscriptionPlan( + input: SubscriptionPlanChangeInput, + ): Promise; + retrieveProduct(id: string): Promise; + retrieveSubscription(id: string): Promise; + cancelSubscription(id: string, idempotencyKey: string): Promise; + parseWebhook(input: RawWebhookRequest): Promise; +} diff --git a/apps/api/src/billing/providers/dodo/index.test.ts b/apps/api/src/billing/providers/dodo/index.test.ts new file mode 100644 index 0000000..d729404 --- /dev/null +++ b/apps/api/src/billing/providers/dodo/index.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("dodopayments", () => ({ + default: class FakeDodoClient { + webhooks = { + unwrap(body: string) { + return JSON.parse(body); + }, + }; + }, +})); + +import { DodoBillingProvider } from "./index"; + +describe("Dodo webhook parsing", () => { + it("accepts a valid delivery whose provider event timestamp is delayed", async () => { + const provider = new DodoBillingProvider({ + DODO_PAYMENTS_API_KEY: "test-token", + DODO_PAYMENTS_WEBHOOK_KEY_CURRENT: "whsec_test", + DODO_PAYMENTS_ENVIRONMENT: "test_mode", + }); + const event = await provider.parseWebhook({ + body: JSON.stringify({ + type: "subscription.updated", + // Provider event timestamps can be delayed on retry. The + // Standard Webhooks delivery timestamp is verified by unwrap. + timestamp: "2026-08-28T00:00:00.000Z", + data: { + subscription_id: "sub_test", + status: "active", + product_id: "pdt_test", + }, + }), + headers: { + "webhook-id": "msg_test", + "webhook-timestamp": String(Math.floor(Date.now() / 1000)), + "webhook-signature": "v1,verified", + }, + }); + expect(event.subscriptionId).toBe("sub_test"); + expect(event.occurredAt.toISOString()).toBe("2026-08-28T00:00:00.000Z"); + }); +}); diff --git a/apps/api/src/billing/providers/dodo/index.ts b/apps/api/src/billing/providers/dodo/index.ts new file mode 100644 index 0000000..ba567d5 --- /dev/null +++ b/apps/api/src/billing/providers/dodo/index.ts @@ -0,0 +1,430 @@ +import DodoPayments from "dodopayments"; +import type { + BillingCustomer, + BillingProductSnapshot, + BillingProviderAdapter, + CanonicalBillingEvent, + Checkout, + PortalSession, + RawWebhookRequest, + SubscriptionPlanChangeInput, + SubscriptionPlanChangeResult, + SubscriptionSnapshot, +} from "../../provider"; +import { BillingProviderError } from "../../provider"; + +function providerError(error: unknown): BillingProviderError { + const status = Number((error as { status?: number })?.status ?? 0); + const message = + error instanceof Error ? error.message : "provider request failed"; + const code = + status === 401 || status === 403 + ? "unauthorized" + : status === 409 + ? "conflict" + : status === 429 + ? "rate_limited" + : status >= 400 && status < 500 + ? "invalid" + : status >= 500 || status === 0 + ? "unavailable" + : "unavailable"; + return new BillingProviderError(code, message, error); +} + +function dateOrNull(value: unknown): Date | null { + if (typeof value !== "string" || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function metadataValue(metadata: unknown, key: string): string | undefined { + if (!metadata || typeof metadata !== "object") return undefined; + const value = (metadata as Record)[key]; + return typeof value === "string" ? value : undefined; +} + +function normalizeSubscription( + data: any, + eventType: string, + occurredAt: Date, +): SubscriptionSnapshot { + const sourceStatus = String(data?.status ?? "pending").toLowerCase(); + const status: SubscriptionSnapshot["status"] = + sourceStatus === "active" + ? "active" + : sourceStatus === "on_hold" || sourceStatus === "paused" + ? "past_due" + : sourceStatus === "cancelled" + ? "cancelled" + : sourceStatus === "expired" || sourceStatus === "failed" + ? "expired" + : sourceStatus === "pending" + ? "pending" + : eventType === "subscription.on_hold" + ? "past_due" + : "pending"; + const trialEndsAt = dateOrNull(data?.trial_end ?? data?.trial_ends_at); + if (status === "active" && trialEndsAt && trialEndsAt > occurredAt) { + return { + provider: "dodo", + providerCustomerId: String( + data?.customer?.customer_id ?? data?.customer_id ?? "", + ), + providerSubscriptionId: String( + data?.subscription_id ?? data?.id ?? "", + ), + providerProductId: String(data?.product_id ?? ""), + status: "trialing", + currentPeriodStartsAt: dateOrNull( + data?.previous_billing_date ?? data?.current_period_start, + ), + currentPeriodEndsAt: dateOrNull( + data?.next_billing_date ?? data?.current_period_end, + ), + paidThroughAt: dateOrNull( + data?.next_billing_date ?? data?.current_period_end, + ), + trialEndsAt, + cancelAtPeriodEnd: Boolean(data?.cancel_at_next_billing_date), + occurredAt, + metadata: { + sendlitCheckoutAttemptId: metadataValue( + data?.metadata, + "sendlitCheckoutAttemptId", + ), + catalogKey: metadataValue(data?.metadata, "catalogKey"), + }, + }; + } + return { + provider: "dodo", + providerCustomerId: String( + data?.customer?.customer_id ?? data?.customer_id ?? "", + ), + providerSubscriptionId: String(data?.subscription_id ?? data?.id ?? ""), + providerProductId: String(data?.product_id ?? ""), + status, + currentPeriodStartsAt: dateOrNull( + data?.previous_billing_date ?? data?.current_period_start, + ), + currentPeriodEndsAt: dateOrNull( + data?.next_billing_date ?? data?.current_period_end, + ), + paidThroughAt: dateOrNull( + data?.next_billing_date ?? + data?.current_period_end ?? + data?.expires_at, + ), + trialEndsAt, + cancelAtPeriodEnd: Boolean(data?.cancel_at_next_billing_date), + occurredAt, + metadata: { + sendlitCheckoutAttemptId: metadataValue( + data?.metadata, + "sendlitCheckoutAttemptId", + ), + catalogKey: metadataValue(data?.metadata, "catalogKey"), + }, + }; +} + +export class DodoBillingProvider implements BillingProviderAdapter { + readonly provider = "dodo" as const; + readonly capabilities = { + planChanges: true, + intervalChanges: true, + // Plan changes are intentionally initiated by SendLit. Keep these + // false even if a Dodo portal configuration later exposes them. + portalPlanChanges: false, + portalIntervalChanges: false, + proratedPlanChanges: true, + } as const; + private readonly client: DodoPayments; + private readonly webhookKeys: Array<{ version: string; key: string }>; + + constructor(env: NodeJS.ProcessEnv = process.env) { + const token = env.DODO_PAYMENTS_API_KEY?.trim(); + if (!token) throw new Error("DODO_PAYMENTS_API_KEY_missing"); + const environment = env.DODO_PAYMENTS_ENVIRONMENT; + if (environment !== "test_mode" && environment !== "live_mode") { + throw new Error("DODO_PAYMENTS_ENVIRONMENT_invalid"); + } + this.client = new DodoPayments({ + bearerToken: token, + environment, + timeout: 10_000, + maxRetries: 0, + webhookKey: env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT ?? null, + }); + const current = env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT?.trim(); + if (!current) + throw new Error("DODO_PAYMENTS_WEBHOOK_KEY_CURRENT_missing"); + this.webhookKeys = [{ version: "current", key: current }]; + const previous = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS?.trim(); + const expires = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT; + if (previous && expires) { + const expiry = new Date(expires); + const max = Date.now() + 48 * 60 * 60 * 1000; + if ( + !Number.isNaN(expiry.getTime()) && + expiry.getTime() > Date.now() && + expiry.getTime() <= max + ) { + this.webhookKeys.push({ version: "previous", key: previous }); + } + } + } + + async createCustomer(input: { + email: string; + name?: string | null; + idempotencyKey: string; + }): Promise { + try { + const customer = await this.client.customers.create( + { email: input.email, name: input.name || input.email }, + { idempotencyKey: input.idempotencyKey }, + ); + return { + provider: this.provider, + providerCustomerId: customer.customer_id, + }; + } catch (error) { + throw providerError(error); + } + } + + async createCheckout(input: { + productId: string; + currency: string; + customerId: string; + payerEmail: string; + returnUrl: string; + cancelUrl?: string; + attemptId: string; + catalogKey: string; + trialDays: number; + idempotencyKey: string; + }): Promise { + try { + const response = await this.client.checkoutSessions.create( + { + product_cart: [ + { product_id: input.productId, quantity: 1 }, + ], + customer: { customer_id: input.customerId }, + billing_currency: input.currency as any, + return_url: input.returnUrl, + cancel_url: input.cancelUrl, + metadata: { + sendlitCheckoutAttemptId: input.attemptId, + catalogKey: input.catalogKey, + }, + subscription_data: + input.trialDays > 0 + ? { trial_period_days: input.trialDays } + : undefined, + }, + { idempotencyKey: input.idempotencyKey }, + ); + if (!response.checkout_url) + throw new Error("provider_checkout_url_missing"); + return { + provider: this.provider, + providerCheckoutSessionId: response.session_id, + checkoutUrl: response.checkout_url, + }; + } catch (error) { + throw providerError(error); + } + } + + async createPortalSession(input: { + customerId: string; + returnUrl: string; + }): Promise { + try { + const response = await this.client.customers.customerPortal.create( + input.customerId, + { + return_url: input.returnUrl, + send_email: false, + }, + ); + return { provider: this.provider, portalUrl: response.link }; + } catch (error) { + throw providerError(error); + } + } + + async changeSubscriptionPlan( + input: SubscriptionPlanChangeInput, + ): Promise { + try { + const response = await this.client.subscriptions.changePlan( + input.providerSubscriptionId, + { + product_id: input.targetProviderProductId, + quantity: 1, + effective_at: input.effectiveAt, + proration_billing_mode: input.prorationMode, + // A failed immediate charge must leave the current plan in + // place; entitlements are only changed by the webhook. + on_payment_failure: "prevent_change", + }, + { idempotencyKey: input.idempotencyKey }, + ); + return { + provider: this.provider, + providerPaymentId: response.payment_id ?? null, + paymentUrl: response.payment_link ?? null, + }; + } catch (error) { + throw providerError(error); + } + } + + private async withReadRetry(fn: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + const mapped = providerError(error); + if ( + mapped.code !== "unavailable" && + mapped.code !== "rate_limited" + ) { + throw mapped; + } + await new Promise((resolve) => + setTimeout( + resolve, + 100 * 2 ** attempt + Math.random() * 50, + ), + ); + } + } + throw providerError(lastError); + } + + async retrieveProduct(id: string): Promise { + try { + const product: any = await this.withReadRetry(() => + this.client.products.retrieve(id), + ); + const price: any = product.price; + if (!price || price.type !== "recurring_price") + throw new Error("provider_product_not_recurring"); + // Dodo exposes both the recurring payment cadence and the overall + // subscription term. The plan interval is the cadence customers + // are charged on; a product may bill monthly while its term is + // configured as a longer period. + const interval = String( + price.payment_frequency_interval ?? + price.subscription_period_interval, + ).toLowerCase(); + if (interval !== "month" && interval !== "year") + throw new Error("provider_product_interval_invalid"); + const amountMinor = Number(price.price); + if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) + throw new Error("provider_product_amount_invalid"); + return { + provider: this.provider, + providerProductId: product.product_id, + currency: String(price.currency).toUpperCase(), + amountMinor, + interval, + }; + } catch (error) { + if (error instanceof BillingProviderError) throw error; + if ( + error instanceof Error && + error.message.startsWith("provider_product_") + ) { + throw new BillingProviderError("invalid", error.message, error); + } + throw providerError(error); + } + } + + async retrieveSubscription(id: string): Promise { + try { + const subscription: any = await this.withReadRetry(() => + this.client.subscriptions.retrieve(id), + ); + return normalizeSubscription( + subscription, + "subscription.updated", + new Date(), + ); + } catch (error) { + throw providerError(error); + } + } + + async cancelSubscription( + id: string, + idempotencyKey: string, + ): Promise { + try { + await this.client.subscriptions.update( + id, + { + status: "cancelled", + cancel_at_next_billing_date: false, + cancel_reason: "cancelled_by_merchant", + }, + { idempotencyKey }, + ); + } catch (error) { + throw providerError(error); + } + } + + async parseWebhook( + input: RawWebhookRequest, + ): Promise { + const eventId = + input.headers["webhook-id"] ?? input.headers["Webhook-Id"]; + if (!eventId) throw new Error("webhook_id_missing"); + let event: any; + let verifiedVersion: string | undefined; + for (const candidate of this.webhookKeys) { + try { + event = this.client.webhooks.unwrap(input.body, { + headers: input.headers, + key: candidate.key, + }); + verifiedVersion = candidate.version; + break; + } catch { + // Try the rotation key, if it is still within its bounded window. + } + } + if (!event || !verifiedVersion) + throw new Error("webhook_signature_invalid"); + const occurredAt = dateOrNull(event.timestamp); + if (!occurredAt) throw new Error("webhook_timestamp_missing"); + // `client.webhooks.unwrap` verifies the Standard Webhooks delivery + // timestamp against Dodo's replay window. The provider event's own + // timestamp describes when the subscription changed and can be much + // older on delayed/retried deliveries, so it must not be used as a + // second freshness gate. + const isSubscription = String(event.type).startsWith("subscription."); + const snapshot = isSubscription + ? normalizeSubscription(event.data, event.type, occurredAt) + : undefined; + return { + provider: this.provider, + providerEventId: eventId, + eventType: String(event.type), + occurredAt, + subscriptionId: snapshot?.providerSubscriptionId, + snapshot, + rawPayload: { ...event, _verifiedKeyVersion: verifiedVersion }, + }; + } +} diff --git a/apps/api/src/billing/providers/fake/index.test.ts b/apps/api/src/billing/providers/fake/index.test.ts new file mode 100644 index 0000000..d60e0d1 --- /dev/null +++ b/apps/api/src/billing/providers/fake/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { BillingProviderError } from "../../provider"; +import { + createContractFake, + runBillingProviderContract, +} from "../../provider-contract"; +import { FakeBillingProvider } from "./index"; + +describe("fake billing provider", () => { + it("satisfies the shared billing-provider contract", async () => { + const { adapter, helpers } = createContractFake(); + await runBillingProviderContract(adapter, helpers); + }); + + it("fails closed on injected provider outages without creating a second checkout", async () => { + const adapter = new FakeBillingProvider(); + adapter.seedDefaultCatalog(); + const customer = await adapter.createCustomer({ + email: "payer@example.com", + idempotencyKey: "customer:outage", + }); + adapter.nextFailure = new BillingProviderError("unavailable", "down"); + await expect( + adapter.createCheckout({ + productId: "pdt_pro_month", + currency: "USD", + customerId: customer.providerCustomerId, + payerEmail: "payer@example.com", + returnUrl: "https://app.test/", + attemptId: "bca_1", + catalogKey: "pro_month", + trialDays: 0, + idempotencyKey: "checkout:outage", + }), + ).rejects.toMatchObject({ code: "unavailable" }); + const checkout = await adapter.createCheckout({ + productId: "pdt_pro_month", + currency: "USD", + customerId: customer.providerCustomerId, + payerEmail: "payer@example.com", + returnUrl: "https://app.test/", + attemptId: "bca_1", + catalogKey: "pro_month", + trialDays: 0, + idempotencyKey: "checkout:outage", + }); + expect(checkout.providerCheckoutSessionId).toMatch(/^cs_/); + }); +}); diff --git a/apps/api/src/billing/providers/fake/index.ts b/apps/api/src/billing/providers/fake/index.ts new file mode 100644 index 0000000..3490f3b --- /dev/null +++ b/apps/api/src/billing/providers/fake/index.ts @@ -0,0 +1,389 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { + BillingCustomer, + BillingProductSnapshot, + BillingProviderAdapter, + CanonicalBillingEvent, + Checkout, + PortalSession, + RawWebhookRequest, + SubscriptionPlanChangeInput, + SubscriptionPlanChangeResult, + SubscriptionSnapshot, +} from "../../provider"; +import { BillingProviderError } from "../../provider"; + +export const FAKE_BILLING_WEBHOOK_KEY = "whsec_fake_test_key"; + +type StoredCheckout = Checkout & { + productId: string; + customerId: string; + attemptId: string; + catalogKey: string; + trialDays: number; + idempotencyKey: string; +}; + +const WEBHOOK_MAX_AGE_SECONDS = 5 * 60; + +function hmac(key: string, payload: string): string { + return createHmac("sha256", key).update(payload, "utf8").digest("hex"); +} + +function equalHex(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** In-memory billing provider for domain tests. It speaks SendLit canonical + * types only — no Dodo SDK, product IDs as opaque strings. */ +export class FakeBillingProvider implements BillingProviderAdapter { + readonly provider = "fake" as const; + readonly capabilities = { + planChanges: true, + intervalChanges: true, + portalPlanChanges: false, + portalIntervalChanges: false, + proratedPlanChanges: true, + } as const; + + private readonly webhookKey: string; + private products = new Map(); + private customersByKey = new Map(); + private customersById = new Map(); + private checkoutsByKey = new Map(); + private checkoutsById = new Map(); + private subscriptions = new Map(); + private nextId = 1; + nextFailure: BillingProviderError | null = null; + + constructor(webhookKey = FAKE_BILLING_WEBHOOK_KEY) { + this.webhookKey = webhookKey; + } + + seedProduct(product: BillingProductSnapshot): void { + this.products.set(product.providerProductId, { + ...product, + provider: this.provider, + }); + } + + seedDefaultCatalog(): void { + const rows: Array< + Pick< + BillingProductSnapshot, + "providerProductId" | "amountMinor" | "interval" + > + > = [ + { + providerProductId: "pdt_pro_month", + amountMinor: 4900, + interval: "month", + }, + { + providerProductId: "pdt_pro_year", + amountMinor: 49000, + interval: "year", + }, + { + providerProductId: "pdt_business_month", + amountMinor: 19900, + interval: "month", + }, + { + providerProductId: "pdt_business_year", + amountMinor: 199000, + interval: "year", + }, + ]; + for (const row of rows) { + this.seedProduct({ + provider: this.provider, + currency: "USD", + ...row, + }); + } + } + + signWebhook( + body: string, + eventId = `evt_${this.nextId++}`, + occurredAt = new Date(), + ) { + const timestamp = String(Math.floor(occurredAt.getTime() / 1000)); + return { + body, + headers: { + "webhook-id": eventId, + "webhook-timestamp": timestamp, + "webhook-signature": `v1,${hmac(this.webhookKey, `${eventId}.${timestamp}.${body}`)}`, + }, + }; + } + + async simulatePayment( + sessionId: string, + occurredAt = new Date(), + ): Promise { + const checkout = this.checkoutsById.get(sessionId); + if (!checkout) + throw new BillingProviderError("invalid", "checkout_not_found"); + const existing = [...this.subscriptions.values()].find( + (row) => + row.metadata.sendlitCheckoutAttemptId === checkout.attemptId, + ); + if (existing) return existing; + const periodEnd = new Date( + occurredAt.getTime() + 30 * 24 * 60 * 60 * 1000, + ); + const trialEndsAt = + checkout.trialDays > 0 + ? new Date( + occurredAt.getTime() + + checkout.trialDays * 24 * 60 * 60 * 1000, + ) + : null; + const snapshot: SubscriptionSnapshot = { + provider: this.provider, + providerCustomerId: checkout.customerId, + providerSubscriptionId: `sub_${this.nextId++}`, + providerProductId: checkout.productId, + status: + trialEndsAt && trialEndsAt > occurredAt ? "trialing" : "active", + currentPeriodStartsAt: occurredAt, + currentPeriodEndsAt: periodEnd, + paidThroughAt: periodEnd, + trialEndsAt, + cancelAtPeriodEnd: false, + occurredAt, + metadata: { + sendlitCheckoutAttemptId: checkout.attemptId, + catalogKey: checkout.catalogKey, + }, + }; + this.subscriptions.set(snapshot.providerSubscriptionId, snapshot); + return snapshot; + } + + private failIfInjected(): void { + if (!this.nextFailure) return; + const error = this.nextFailure; + this.nextFailure = null; + throw error; + } + + async createCustomer(input: { + email: string; + name?: string | null; + idempotencyKey: string; + }): Promise { + this.failIfInjected(); + const existing = this.customersByKey.get(input.idempotencyKey); + if (existing) return existing; + const customer: BillingCustomer = { + provider: this.provider, + providerCustomerId: `cus_${this.nextId++}`, + }; + this.customersByKey.set(input.idempotencyKey, customer); + this.customersById.set(customer.providerCustomerId, customer); + return customer; + } + + async createCheckout(input: { + productId: string; + currency: string; + customerId: string; + payerEmail: string; + returnUrl: string; + cancelUrl?: string; + attemptId: string; + catalogKey: string; + trialDays: number; + idempotencyKey: string; + }): Promise { + this.failIfInjected(); + if (!this.customersById.has(input.customerId)) { + throw new BillingProviderError("invalid", "customer_not_found"); + } + if (!this.products.has(input.productId)) { + throw new BillingProviderError("invalid", "product_not_found"); + } + const existing = this.checkoutsByKey.get(input.idempotencyKey); + if (existing) { + return { + provider: existing.provider, + providerCheckoutSessionId: existing.providerCheckoutSessionId, + checkoutUrl: existing.checkoutUrl, + }; + } + const sessionId = `cs_${this.nextId++}`; + const stored: StoredCheckout = { + provider: this.provider, + providerCheckoutSessionId: sessionId, + checkoutUrl: `https://billing.test/checkout/${sessionId}`, + productId: input.productId, + customerId: input.customerId, + attemptId: input.attemptId, + catalogKey: input.catalogKey, + trialDays: input.trialDays, + idempotencyKey: input.idempotencyKey, + }; + this.checkoutsByKey.set(input.idempotencyKey, stored); + this.checkoutsById.set(sessionId, stored); + return { + provider: stored.provider, + providerCheckoutSessionId: stored.providerCheckoutSessionId, + checkoutUrl: stored.checkoutUrl, + }; + } + + async createPortalSession(input: { + customerId: string; + returnUrl: string; + }): Promise { + this.failIfInjected(); + if (!this.customersById.has(input.customerId)) { + throw new BillingProviderError("invalid", "customer_not_found"); + } + return { + provider: this.provider, + portalUrl: `https://billing.test/portal/${input.customerId}?return=${encodeURIComponent(input.returnUrl)}`, + }; + } + + async changeSubscriptionPlan( + input: SubscriptionPlanChangeInput, + ): Promise { + this.failIfInjected(); + const current = this.subscriptions.get(input.providerSubscriptionId); + if (!current) + throw new BillingProviderError("invalid", "subscription_not_found"); + if (!this.products.has(input.targetProviderProductId)) { + throw new BillingProviderError("invalid", "product_not_found"); + } + if (input.effectiveAt === "immediately") { + this.subscriptions.set(input.providerSubscriptionId, { + ...current, + providerProductId: input.targetProviderProductId, + occurredAt: new Date(), + }); + } + return { + provider: this.provider, + providerPaymentId: + input.prorationMode === "prorated_immediately" + ? `pay_${this.nextId++}` + : null, + paymentUrl: null, + }; + } + + async retrieveProduct(id: string): Promise { + this.failIfInjected(); + const product = this.products.get(id); + if (!product) + throw new BillingProviderError("invalid", "product_not_found"); + return { ...product }; + } + + async retrieveSubscription(id: string): Promise { + this.failIfInjected(); + const subscription = this.subscriptions.get(id); + if (!subscription) { + throw new BillingProviderError("invalid", "subscription_not_found"); + } + return { ...subscription, occurredAt: new Date() }; + } + + async cancelSubscription( + id: string, + _idempotencyKey: string, + ): Promise { + this.failIfInjected(); + const current = this.subscriptions.get(id); + if (!current) + throw new BillingProviderError("invalid", "subscription_not_found"); + this.subscriptions.set(id, { + ...current, + status: "cancelled", + cancelAtPeriodEnd: false, + occurredAt: new Date(), + }); + } + + async parseWebhook( + input: RawWebhookRequest, + ): Promise { + const eventId = + input.headers["webhook-id"] ?? input.headers["Webhook-Id"]; + const timestamp = + input.headers["webhook-timestamp"] ?? + input.headers["Webhook-Timestamp"]; + const signature = + input.headers["webhook-signature"] ?? + input.headers["Webhook-Signature"]; + if (!eventId || !timestamp || !signature) { + throw new Error("webhook_signature_invalid"); + } + const age = Math.abs(Date.now() / 1000 - Number(timestamp)); + if (!Number.isFinite(age) || age > WEBHOOK_MAX_AGE_SECONDS) { + throw new Error("webhook_timestamp_stale"); + } + const expected = `v1,${hmac(this.webhookKey, `${eventId}.${timestamp}.${input.body}`)}`; + if (!equalHex(signature, expected)) { + throw new Error("webhook_signature_invalid"); + } + const event = JSON.parse(input.body) as { + type?: string; + data?: { + subscription_id?: string; + customer_id?: string; + product_id?: string; + status?: string; + metadata?: { + sendlitCheckoutAttemptId?: string; + catalogKey?: string; + }; + }; + }; + const occurredAt = new Date(Number(timestamp) * 1000); + const subscriptionId = event.data?.subscription_id; + const stored = subscriptionId + ? this.subscriptions.get(subscriptionId) + : undefined; + const snapshot = stored + ? { ...stored, occurredAt } + : event.data?.subscription_id + ? { + provider: this.provider, + providerCustomerId: event.data.customer_id ?? "", + providerSubscriptionId: event.data.subscription_id, + providerProductId: event.data.product_id ?? "", + status: + (event.data.status as SubscriptionSnapshot["status"]) ?? + "pending", + currentPeriodStartsAt: null, + currentPeriodEndsAt: null, + paidThroughAt: null, + trialEndsAt: null, + cancelAtPeriodEnd: false, + occurredAt, + metadata: { + sendlitCheckoutAttemptId: + event.data.metadata?.sendlitCheckoutAttemptId, + catalogKey: event.data.metadata?.catalogKey, + }, + } + : undefined; + return { + provider: this.provider, + providerEventId: eventId, + eventType: String(event.type ?? "unknown"), + occurredAt, + subscriptionId, + snapshot, + rawPayload: event, + }; + } +} diff --git a/apps/api/src/billing/reconciliation.ts b/apps/api/src/billing/reconciliation.ts new file mode 100644 index 0000000..3f2ca7c --- /dev/null +++ b/apps/api/src/billing/reconciliation.ts @@ -0,0 +1,526 @@ +import { + and, + eq, + gt, + isNotNull, + isNull, + lte, + lt, + or, + inArray, + sql, +} from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCheckoutAttempts, + billingPlanChangeAttempts, + billingPriceEntries, + billingWebhookEvents, + organizationSubscriptions, + organizations, + planSendReservations, + sendingDomains, +} from "../db/schema"; +import { readBillingConfig } from "./catalog"; +import { getBillingProvider } from "./provider-registry"; +import { + recordRequestedCatalogRevision, + verifyCatalogAgainstProvider, +} from "./catalog-store"; +import { recordBillingMetric } from "./metrics"; +import { providerErrorSummary } from "./provider"; +import { + applyCanonicalBillingEvent, + claimBillingWebhookEvent, + expireCancelledSubscriptionEntitlements, + processBillingWebhookInboxEvent, +} from "./webhooks/processor"; +import { resumeOrganizationCheckoutAttempt } from "./checkout"; +import { settleExpiredSendReservation } from "./entitlements"; +import { evaluateAllTeamReputations } from "./reputation"; +import { verifySendingDomain } from "./domains"; +import { encryptBillingValue } from "./crypto"; +import { evaluateBillingSloAlerts, recordBillingHourlySuccess } from "./alerts"; +import logger from "../services/log"; + +let hourlyTimer: NodeJS.Timeout | undefined; +let inboxTimer: NodeJS.Timeout | undefined; +let hourlyRunning = false; +let inboxRunning = false; + +export async function processBillingInboxOnce(now = new Date()): Promise { + const inbox = await db + .select({ id: billingWebhookEvents.id }) + .from(billingWebhookEvents) + .where( + or( + eq(billingWebhookEvents.status, "pending"), + and( + eq(billingWebhookEvents.status, "failed"), + lte(billingWebhookEvents.availableAt, now), + ), + and( + eq(billingWebhookEvents.status, "processing"), + lt(billingWebhookEvents.leaseExpiresAt, now), + ), + ), + ) + .limit(100); + for (const event of inbox) { + if (await claimBillingWebhookEvent(event.id, now)) { + await processBillingWebhookInboxEvent(event.id).catch((error) => { + logger.error( + { + billing_webhook_event_id: event.id, + error: providerErrorSummary(error), + }, + "billing webhook inbox event failed", + ); + }); + } + } +} + +export async function settleExpiredSendReservationsOnce( + now = new Date(), +): Promise { + const expiredReservations = await db + .select({ outboundMessageId: planSendReservations.outboundMessageId }) + .from(planSendReservations) + .where( + and( + eq(planSendReservations.state, "reserved"), + lte(planSendReservations.expiresAt, now), + ), + ) + .limit(500); + for (const reservation of expiredReservations) { + await settleExpiredSendReservation( + reservation.outboundMessageId, + now, + ).catch((error) => { + logger.error( + { + outbound_message_id: reservation.outboundMessageId, + error: providerErrorSummary(error), + }, + "expired send reservation settlement failed", + ); + }); + } +} + +export async function reconcileBillingOnce(now = new Date()): Promise { + let config; + try { + config = readBillingConfig(); + } catch { + return; + } + if (config.deploymentMode !== "cloud") return; + try { + await recordRequestedCatalogRevision(config); + const provider = getBillingProvider( + config.checkoutProvider ?? undefined, + ); + await verifyCatalogAgainstProvider(config, provider); + } catch (error) { + logger.error( + { error: providerErrorSummary(error) }, + "billing catalog verification failed", + ); + recordBillingMetric("billing.catalog.verify_failed", {}); + } + const cutoff = new Date(now.getTime() - 60 * 60 * 1000); + const attempts = await db + .update(billingCheckoutAttempts) + .set({ + status: "expired", + completedAt: now, + checkoutUrlEncrypted: null, + updatedAt: now, + }) + .where( + and( + inArray(billingCheckoutAttempts.status, ["creating", "open"]), + lt(billingCheckoutAttempts.expiresAt, now), + ), + ) + .returning({ id: billingCheckoutAttempts.id }); + if (attempts.length) + logger.info( + { billing_checkout_expired: attempts.length }, + "billing checkout attempts expired", + ); + const creatingAttempts = await db + .select({ id: billingCheckoutAttempts.id }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.status, "creating"), + lt( + billingCheckoutAttempts.updatedAt, + new Date(now.getTime() - 60 * 1000), + ), + gt(billingCheckoutAttempts.expiresAt, now), + ), + ) + .limit(100); + for (const attempt of creatingAttempts) { + const [claimed] = await db + .update(billingCheckoutAttempts) + .set({ updatedAt: now }) + .where( + and( + eq(billingCheckoutAttempts.id, attempt.id), + eq(billingCheckoutAttempts.status, "creating"), + lt( + billingCheckoutAttempts.updatedAt, + new Date(now.getTime() - 60 * 1000), + ), + gt(billingCheckoutAttempts.expiresAt, now), + ), + ) + .returning({ id: billingCheckoutAttempts.id }); + if (claimed) { + await resumeOrganizationCheckoutAttempt(claimed.id, now).catch( + (error) => { + logger.warn( + { + billing_checkout_attempt: claimed.id, + error: providerErrorSummary(error), + }, + "billing checkout resume failed", + ); + }, + ); + } + } + // A reserved trial is never released merely because its local checkout + // window elapsed: a provider may have created a subscription while the + // response was lost. Release is an explicit/operator-reconciled action. + await expireCancelledSubscriptionEntitlements(now); + // Paid-organization creation is intentionally two-phase: a pending org is + // retained while checkout can still arrive, then tombstoned after its + // attempts have expired/abandoned. Data and correlation rows are never + // deleted, so late provider events can only be quarantined. + const pendingOrganizations = await db + .select({ id: organizations.id }) + .from(organizations) + .where( + and( + eq(organizations.status, "pending_payment"), + lt( + organizations.createdAt, + new Date(now.getTime() - 24 * 60 * 60 * 1000), + ), + ), + ) + .limit(100); + for (const organization of pendingOrganizations) { + const [attempt] = await db + .select({ id: billingCheckoutAttempts.id }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.organizationId, organization.id), + inArray(billingCheckoutAttempts.status, [ + "creating", + "open", + ]), + ), + ) + .limit(1); + if (!attempt) { + await db + .update(organizations) + .set({ status: "abandoned", updatedAt: now }) + .where( + and( + eq(organizations.id, organization.id), + eq(organizations.status, "pending_payment"), + ), + ); + } + } + const dueDomains = await db + .select({ + organizationId: sendingDomains.organizationId, + domainId: sendingDomains.domainId, + }) + .from(sendingDomains) + .where( + and( + inArray(sendingDomains.status, [ + "pending", + "verified", + "failed", + ]), + lt(sendingDomains.nextCheckAt, now), + ), + ) + .limit(100); + for (const domain of dueDomains) { + // Claim the check briefly so multiple API instances do not count the + // same failed DNS lookup several times in one sweep. + const [claimed] = await db + .update(sendingDomains) + .set({ + nextCheckAt: new Date(now.getTime() + 60 * 60 * 1000), + updatedAt: now, + }) + .where( + and( + eq(sendingDomains.domainId, domain.domainId), + lt(sendingDomains.nextCheckAt, now), + ), + ) + .returning({ + organizationId: sendingDomains.organizationId, + domainId: sendingDomains.domainId, + }); + if (claimed) { + await verifySendingDomain( + claimed.organizationId, + claimed.domainId, + ).catch(() => { + logger.warn( + { sending_domain_check: claimed.domainId }, + "sending domain verification failed", + ); + }); + } + } + await settleExpiredSendReservationsOnce(now); + await processBillingInboxOnce(now); + const subscriptions = await db + .select() + .from(organizationSubscriptions) + .where( + and( + inArray(organizationSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + "cancelled", + ]), + or( + isNull(organizationSubscriptions.lastReconciledAt), + lt(organizationSubscriptions.lastReconciledAt, cutoff), + ), + ), + ) + .limit(100); + for (const candidate of subscriptions) { + const [subscription] = await db + .select() + .from(organizationSubscriptions) + .where(eq(organizationSubscriptions.id, candidate.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!subscription) continue; + try { + const provider = getBillingProvider(subscription.provider); + const snapshot = await provider.retrieveSubscription( + subscription.providerSubscriptionId, + ); + await applyCanonicalBillingEvent({ + provider: subscription.provider, + providerEventId: `reconcile:${subscription.id}:${snapshot.occurredAt.toISOString()}`, + eventType: "subscription.reconciled", + occurredAt: snapshot.occurredAt, + subscriptionId: snapshot.providerSubscriptionId, + snapshot, + rawPayload: null, + }); + await db + .update(organizationSubscriptions) + .set({ lastReconciledAt: now, updatedAt: now }) + .where(eq(organizationSubscriptions.id, subscription.id)); + } catch (error) { + // Provider outages and quarantined records are isolated; the next + // hourly pass retries them without affecting other organizations. + logger.warn( + { + billing_reconciliation_subscription: subscription.id, + error: providerErrorSummary(error), + }, + "billing subscription reconciliation failed", + ); + } + } + // Retry ambiguous plan-change mutations with the same provider + // idempotency key. A provider that already applied the request returns the + // original result; a scheduled change may safely remain pending until its + // next-billing snapshot arrives. + const planChanges = await db + .select({ + attempt: billingPlanChangeAttempts, + subscription: organizationSubscriptions, + price: billingPriceEntries, + }) + .from(billingPlanChangeAttempts) + .innerJoin( + organizationSubscriptions, + eq( + organizationSubscriptions.id, + billingPlanChangeAttempts.subscriptionId, + ), + ) + .innerJoin( + billingPriceEntries, + eq( + billingPriceEntries.id, + billingPlanChangeAttempts.targetBillingPriceEntryId, + ), + ) + .where( + and( + or( + eq(billingPlanChangeAttempts.status, "creating"), + and( + eq(billingPlanChangeAttempts.status, "pending"), + isNotNull(billingPlanChangeAttempts.lastError), + ), + ), + or( + isNull(billingPlanChangeAttempts.updatedAt), + lt(billingPlanChangeAttempts.updatedAt, cutoff), + ), + ), + ) + .limit(100); + for (const { attempt, subscription, price } of planChanges) { + const [claimed] = await db + .update(billingPlanChangeAttempts) + .set({ updatedAt: now }) + .where( + and( + eq(billingPlanChangeAttempts.id, attempt.id), + or( + eq(billingPlanChangeAttempts.status, "creating"), + and( + eq(billingPlanChangeAttempts.status, "pending"), + isNotNull(billingPlanChangeAttempts.lastError), + ), + ), + or( + isNull(billingPlanChangeAttempts.updatedAt), + lt(billingPlanChangeAttempts.updatedAt, cutoff), + ), + ), + ) + .returning({ id: billingPlanChangeAttempts.id }); + if (!claimed) continue; + try { + const provider = getBillingProvider(attempt.provider); + const result = await provider.changeSubscriptionPlan({ + providerSubscriptionId: subscription.providerSubscriptionId, + targetProviderProductId: price.providerProductId, + effectiveAt: attempt.effectiveAt as + "immediately" | "next_billing_date", + prorationMode: attempt.prorationMode as + "prorated_immediately" | "do_not_bill", + idempotencyKey: attempt.idempotencyKey, + }); + await db + .update(billingPlanChangeAttempts) + .set({ + providerPaymentId: result.providerPaymentId, + paymentUrlEncrypted: result.paymentUrl + ? encryptBillingValue(result.paymentUrl) + : attempt.paymentUrlEncrypted, + lastError: null, + updatedAt: now, + }) + .where(eq(billingPlanChangeAttempts.id, attempt.id)); + } catch (error) { + await db + .update(billingPlanChangeAttempts) + .set({ + lastError: providerErrorSummary(error), + updatedAt: now, + }) + .where(eq(billingPlanChangeAttempts.id, attempt.id)); + } + } + // Webhook bodies contain provider metadata and are retained only for the + // documented replay window. The durable event status and subscription + // projection remain available for audit after the encrypted payload is + // purged. + await db + .update(billingWebhookEvents) + .set({ payloadEncrypted: null }) + .where( + and( + inArray(billingWebhookEvents.status, [ + "processed", + "ignored", + "quarantined", + ]), + lt( + sql`coalesce(${billingWebhookEvents.processedAt}, ${billingWebhookEvents.receivedAt})`, + new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + ), + ), + ); + await evaluateAllTeamReputations(now); + recordBillingHourlySuccess(now); + await evaluateBillingSloAlerts(now).catch((error) => { + logger.error( + { error: providerErrorSummary(error) }, + "billing SLO evaluation failed", + ); + }); +} + +async function runHourlyBillingSweep(failureMessage: string): Promise { + if (hourlyRunning) return; + hourlyRunning = true; + try { + await reconcileBillingOnce(); + } catch (error) { + logger.error({ error: providerErrorSummary(error) }, failureMessage); + } finally { + hourlyRunning = false; + } +} + +async function runBillingInboxSweep(): Promise { + if (inboxRunning) return; + inboxRunning = true; + try { + const now = new Date(); + const results = await Promise.allSettled([ + processBillingInboxOnce(now), + settleExpiredSendReservationsOnce(now), + ]); + for (const result of results) { + if (result.status === "rejected") { + logger.error( + { error: providerErrorSummary(result.reason) }, + "billing maintenance sweep failed", + ); + } + } + } finally { + inboxRunning = false; + } +} + +export function startBillingReconciliation(): void { + if (hourlyTimer || inboxTimer) return; + void runHourlyBillingSweep("initial billing reconciliation failed"); + hourlyTimer = setInterval( + () => { + void runHourlyBillingSweep("billing reconciliation sweep failed"); + }, + 60 * 60 * 1000, + ); + inboxTimer = setInterval(() => { + void runBillingInboxSweep(); + }, 5 * 1000); + hourlyTimer.unref?.(); + inboxTimer.unref?.(); +} diff --git a/apps/api/src/billing/reputation-config.ts b/apps/api/src/billing/reputation-config.ts new file mode 100644 index 0000000..a66ce93 --- /dev/null +++ b/apps/api/src/billing/reputation-config.ts @@ -0,0 +1,60 @@ +export type ReputationConfig = { + minimumAccepted: number; + bounceWarnBps: number; + complaintWarnBps: number; + bouncePauseBps: number; + complaintPauseBps: number; + complaintStopBps: number; + complaintStopAbsolute: number; + transactionalDailyLimit: number; + minimumHoldHours: number; + recoveryCleanDays: number; +}; + +function positiveInt( + name: string, + fallback: number, + max = 2_147_483_647, +): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + if (!/^\d+$/.test(raw)) throw new Error(`${name}_invalid`); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0 || value > max) + throw new Error(`${name}_invalid`); + return value; +} + +/** Fair-use thresholds are deployment configuration, not route constants. */ +export function readReputationConfig(): ReputationConfig { + return { + minimumAccepted: positiveInt("BILLING_FAIR_USE_MIN_ACCEPTED", 500), + bounceWarnBps: positiveInt("BILLING_FAIR_USE_BOUNCE_WARN_BPS", 200), + complaintWarnBps: positiveInt("BILLING_FAIR_USE_COMPLAINT_WARN_BPS", 5), + bouncePauseBps: positiveInt("BILLING_FAIR_USE_BOUNCE_PAUSE_BPS", 500), + complaintPauseBps: positiveInt( + "BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS", + 10, + ), + complaintStopBps: positiveInt( + "BILLING_FAIR_USE_COMPLAINT_STOP_BPS", + 30, + ), + complaintStopAbsolute: positiveInt( + "BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE", + 10, + ), + transactionalDailyLimit: positiveInt( + "BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT", + 100, + ), + minimumHoldHours: positiveInt( + "BILLING_FAIR_USE_MINIMUM_HOLD_HOURS", + 72, + ), + recoveryCleanDays: positiveInt( + "BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS", + 7, + ), + }; +} diff --git a/apps/api/src/billing/reputation-policy.ts b/apps/api/src/billing/reputation-policy.ts new file mode 100644 index 0000000..135e7f7 --- /dev/null +++ b/apps/api/src/billing/reputation-policy.ts @@ -0,0 +1,63 @@ +import type { ReputationConfig } from "./reputation-config"; + +export type ReputationMetrics = { + accepted: number; + bounced: number; + complained: number; +}; + +export type ReputationDecision = { + status: "normal" | "warned" | "marketing_paused" | "all_paused"; + reason: string; +}; + +function rateAtLeast( + numerator: number, + denominator: number, + thresholdBps: number, +): boolean { + return denominator > 0 && numerator * 10_000 >= denominator * thresholdBps; +} + +export function reputationDecision( + metrics: ReputationMetrics, + config: ReputationConfig, + evaluateRates: boolean, +): ReputationDecision { + if (metrics.complained >= config.complaintStopAbsolute) { + return { status: "all_paused", reason: "complaint_stop" }; + } + if (!evaluateRates) { + return { status: "normal", reason: "reputation_clean" }; + } + if ( + rateAtLeast( + metrics.complained, + metrics.accepted, + config.complaintStopBps, + ) + ) { + return { status: "all_paused", reason: "complaint_stop" }; + } + if ( + rateAtLeast(metrics.bounced, metrics.accepted, config.bouncePauseBps) || + rateAtLeast( + metrics.complained, + metrics.accepted, + config.complaintPauseBps, + ) + ) { + return { status: "marketing_paused", reason: "reputation_pause" }; + } + if ( + rateAtLeast(metrics.bounced, metrics.accepted, config.bounceWarnBps) || + rateAtLeast( + metrics.complained, + metrics.accepted, + config.complaintWarnBps, + ) + ) { + return { status: "warned", reason: "reputation_warning" }; + } + return { status: "normal", reason: "reputation_clean" }; +} diff --git a/apps/api/src/billing/reputation.test.ts b/apps/api/src/billing/reputation.test.ts new file mode 100644 index 0000000..044207e --- /dev/null +++ b/apps/api/src/billing/reputation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { ReputationConfig } from "./reputation-config"; +import { reputationDecision } from "./reputation-policy"; + +const config: ReputationConfig = { + minimumAccepted: 500, + bounceWarnBps: 200, + complaintWarnBps: 5, + bouncePauseBps: 500, + complaintPauseBps: 10, + complaintStopBps: 30, + complaintStopAbsolute: 10, + transactionalDailyLimit: 100, + minimumHoldHours: 72, + recoveryCleanDays: 7, +}; + +describe("fair-use reputation decisions", () => { + it("does not percentage-pause statistically small samples", () => { + expect( + reputationDecision( + { accepted: 20, bounced: 3, complained: 0 }, + config, + false, + ), + ).toEqual({ status: "normal", reason: "reputation_clean" }); + }); + + it("stops an absolute complaint burst even below the rate sample floor", () => { + expect( + reputationDecision( + { accepted: 20, bounced: 0, complained: 10 }, + config, + false, + ), + ).toEqual({ status: "all_paused", reason: "complaint_stop" }); + }); + + it("applies percentage thresholds once enough mail was accepted", () => { + expect( + reputationDecision( + { accepted: 500, bounced: 25, complained: 0 }, + config, + true, + ), + ).toEqual({ status: "marketing_paused", reason: "reputation_pause" }); + }); +}); diff --git a/apps/api/src/billing/reputation.ts b/apps/api/src/billing/reputation.ts new file mode 100644 index 0000000..696cf9d --- /dev/null +++ b/apps/api/src/billing/reputation.ts @@ -0,0 +1,393 @@ +import { and, count, eq, gt, gte, lt, sql } from "drizzle-orm"; +import { db } from "../db/client"; +import logger from "../services/log"; +import { + organizationPlanStates, + organizationAuditEvents, + outboundMessages, + planSendReservations, + teamSendingControls, + teams, +} from "../db/schema"; +import { getOrganizationEntitlements } from "./entitlements"; +import { readReputationConfig } from "./reputation-config"; +import { notifyReputationChange } from "./notifications"; +import { + reputationDecision, + type ReputationMetrics, +} from "./reputation-policy"; +export { readReputationConfig } from "./reputation-config"; + +export type SendingControlStatus = + "normal" | "warned" | "marketing_paused" | "all_paused"; + +function utcDayStart(now: Date): Date { + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); +} + +function sameUtcDay(a: Date | null, b: Date): boolean { + return Boolean(a && utcDayStart(a).getTime() === utcDayStart(b).getTime()); +} + +async function rollingMetrics( + teamId: string, + now: Date, +): Promise { + const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const [row] = await db + .select({ + accepted: count( + sql`CASE WHEN ${outboundMessages.acceptedAt} IS NOT NULL THEN 1 END`, + ), + bounced: count( + sql`CASE WHEN ${outboundMessages.acceptedAt} IS NOT NULL AND ${outboundMessages.deliveryStatus} = 'bounced' THEN 1 END`, + ), + complained: count( + sql`CASE WHEN ${outboundMessages.acceptedAt} IS NOT NULL AND ${outboundMessages.feedbackStatus} = 'complained' THEN 1 END`, + ), + }) + .from(outboundMessages) + .where( + and( + eq(outboundMessages.teamId, teamId), + gte(outboundMessages.acceptedAt, since), + lt(outboundMessages.acceptedAt, now), + ), + ); + return { + accepted: Number(row?.accepted ?? 0), + bounced: Number(row?.bounced ?? 0), + complained: Number(row?.complained ?? 0), + }; +} + +/** + * Re-evaluates one team's seven-day reputation window. The state transition is + * idempotent and row-locked; all-pause is deliberately sticky until an + * operator releases it. Metrics are based on the deduplicated outbound ledger + * projection, so retries and duplicate provider events cannot inflate rates. + */ +export async function evaluateTeamReputation( + teamId: string, + now = new Date(), +): Promise { + if (process.env.SENDLIT_DEPLOYMENT_MODE !== "cloud") return; + const [team] = await db + .select({ id: teams.id, organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) return; + const entitlements = await getOrganizationEntitlements( + team.organizationId, + now, + ); + if (!entitlements.fairUse) return; + + const metrics = await rollingMetrics(teamId, now); + const config = readReputationConfig(); + const evaluateRates = metrics.accepted >= config.minimumAccepted; + if (!evaluateRates && metrics.complained < config.complaintStopAbsolute) + return; + const desired = reputationDecision(metrics, config, evaluateRates); + + await db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(teamSendingControls) + .where(eq(teamSendingControls.teamId, teamId)) + .limit(1) + .for("update"); + const control = + existing ?? + ( + await tx + .insert(teamSendingControls) + .values({ teamId, status: "normal" }) + .returning() + )[0]; + if (!control) throw new Error("sending_control_unavailable"); + if (control.status === "all_paused") return; + + // Recovery streaks advance at most once per UTC day. Any breach resets + // the streak immediately, and a marketing pause cannot recover before + // its minimum hold has elapsed. + const alreadyEvaluatedToday = sameUtcDay(control.evaluatedAt, now); + let nextStatus = desired.status; + let cleanDays = control.cleanEvaluationDays; + let reason = desired.reason; + let minimumHoldUntil = control.minimumHoldUntil; + const rank = (status: SendingControlStatus) => + status === "all_paused" + ? 3 + : status === "marketing_paused" + ? 2 + : status === "warned" + ? 1 + : 0; + if ( + rank(desired.status) > rank(control.status as SendingControlStatus) + ) { + cleanDays = 0; + if (desired.status === "marketing_paused") { + minimumHoldUntil = new Date( + now.getTime() + config.minimumHoldHours * 60 * 60 * 1000, + ); + } + } else if ( + rank(desired.status) < rank(control.status as SendingControlStatus) + ) { + // Recovery never skips the current hold. marketing_paused stays + // paused until 72 hours plus seven clean days; warned needs seven + // clean days. Weaker samples cannot drop a pause to warned. + if ( + control.status === "marketing_paused" || + control.status === "warned" + ) { + if (desired.status === "normal" && !alreadyEvaluatedToday) { + cleanDays += 1; + } else if (desired.status !== "normal") { + cleanDays = 0; + } + const holdElapsed = + !minimumHoldUntil || + minimumHoldUntil.getTime() <= now.getTime(); + if (cleanDays >= config.recoveryCleanDays && holdElapsed) { + nextStatus = "normal"; + reason = "reputation_recovered"; + minimumHoldUntil = null; + } else { + nextStatus = control.status as SendingControlStatus; + reason = control.reasonCode ?? "reputation_pause"; + } + } + } else if ( + desired.status === "warned" || + desired.status === "marketing_paused" + ) { + cleanDays = 0; + } + + if (nextStatus !== control.status || !alreadyEvaluatedToday) { + const enteredAt = + nextStatus !== control.status ? now : control.enteredAt; + await tx + .update(teamSendingControls) + .set({ + status: nextStatus, + reasonCode: reason, + source: "automatic", + enteredAt, + evaluatedAt: now, + minimumHoldUntil, + cleanEvaluationDays: cleanDays, + updatedAt: now, + }) + .where(eq(teamSendingControls.id, control.id)); + if (nextStatus !== control.status) { + await tx.insert(organizationAuditEvents).values({ + organizationId: team.organizationId, + teamId, + actorType: "system", + action: `reputation_${nextStatus}`, + metadata: { + reason, + accepted: metrics.accepted, + bounced: metrics.bounced, + complained: metrics.complained, + }, + }); + void notifyReputationChange({ + organizationId: team.organizationId, + teamId, + status: nextStatus, + reason, + }).catch(() => undefined); + } + } + }); +} + +/** Hourly sweep; event processing also calls the single-team evaluator. */ +export async function evaluateAllTeamReputations( + now = new Date(), +): Promise { + if (process.env.SENDLIT_DEPLOYMENT_MODE !== "cloud") return; + const rows = await db + .select({ id: teams.id }) + .from(teams) + .innerJoin( + organizationPlanStates, + eq(organizationPlanStates.organizationId, teams.organizationId), + ) + .where(sql`${teams.status} IN ('active', 'sending_suspended')`); + for (const row of rows) { + await evaluateTeamReputation(row.id, now).catch(() => { + logger.warn( + { billing_reputation_team: row.id }, + "team reputation evaluation failed", + ); + }); + } +} + +/** Audited operator release for a sticky all-pause. It starts at warned so a + * subsequent clean streak is observable; payment entitlements remain the + * independent final authority and are never changed by this operation. */ +export async function applyTeamSendingControl( + teamId: string, + status: SendingControlStatus, + operatorUserId: string, + operatorReason: string, +): Promise { + const reason = operatorReason.trim(); + if (!reason || reason.length > 500) + throw new Error("operator_reason_invalid"); + if (status === "normal") { + throw new Error("operator_must_release_all_pause"); + } + return db.transaction(async (tx) => { + const [team] = await tx + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1) + .for("update"); + if (!team) return false; + const [existing] = await tx + .select() + .from(teamSendingControls) + .where(eq(teamSendingControls.teamId, teamId)) + .limit(1) + .for("update"); + const now = new Date(); + if (existing) { + await tx + .update(teamSendingControls) + .set({ + status, + source: "operator", + operatorUserId, + operatorReason: reason, + overriddenAt: now, + enteredAt: now, + evaluatedAt: now, + cleanEvaluationDays: 0, + updatedAt: now, + }) + .where(eq(teamSendingControls.id, existing.id)); + } else { + await tx.insert(teamSendingControls).values({ + teamId, + status, + source: "operator", + operatorUserId, + operatorReason: reason, + overriddenAt: now, + }); + } + await tx.insert(organizationAuditEvents).values({ + organizationId: team.organizationId, + teamId, + actorType: "user", + actorId: operatorUserId, + action: "reputation_operator_apply", + metadata: { reason, status }, + }); + return true; + }); +} + +export async function releaseTeamSendingControl( + teamId: string, + operatorUserId: string, + operatorReason: string, +): Promise { + const reason = operatorReason.trim(); + if (!reason || reason.length > 500) + throw new Error("operator_reason_invalid"); + return db.transaction(async (tx) => { + const [team] = await tx + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1) + .for("update"); + if (!team) return false; + const [control] = await tx + .select() + .from(teamSendingControls) + .where(eq(teamSendingControls.teamId, teamId)) + .limit(1) + .for("update"); + if (!control || control.status !== "all_paused") return false; + const now = new Date(); + await tx + .update(teamSendingControls) + .set({ + status: "warned", + source: "operator", + operatorUserId, + operatorReason: reason, + overriddenAt: now, + enteredAt: now, + evaluatedAt: now, + cleanEvaluationDays: 0, + updatedAt: now, + }) + .where(eq(teamSendingControls.id, control.id)); + await tx.insert(organizationAuditEvents).values({ + organizationId: team.organizationId, + teamId, + actorType: "user", + actorId: operatorUserId, + action: "reputation_operator_release", + metadata: { reason }, + }); + return true; + }); +} + +export async function transactionalUsageToday( + teamId: string, + now = new Date(), +): Promise<{ accepted: number; reserved: number }> { + const start = utcDayStart(now); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + const [[accepted], [reserved]] = await Promise.all([ + db + .select({ value: count() }) + .from(outboundMessages) + .where( + and( + eq(outboundMessages.teamId, teamId), + eq(outboundMessages.sourceType, "transactional"), + gte(outboundMessages.acceptedAt, start), + lt(outboundMessages.acceptedAt, end), + ), + ), + db + .select({ value: count() }) + .from(outboundMessages) + .innerJoin( + planSendReservations, + eq(planSendReservations.outboundMessageId, outboundMessages.id), + ) + .where( + and( + eq(outboundMessages.teamId, teamId), + eq(outboundMessages.sourceType, "transactional"), + eq(planSendReservations.state, "reserved"), + gt(planSendReservations.expiresAt, now), + gte(planSendReservations.updatedAt, start), + lt(planSendReservations.updatedAt, end), + ), + ), + ]); + return { + accepted: Number(accepted?.value ?? 0), + reserved: Number(reserved?.value ?? 0), + }; +} diff --git a/apps/api/src/billing/routes.csrf.test.ts b/apps/api/src/billing/routes.csrf.test.ts new file mode 100644 index 0000000..dd1bf20 --- /dev/null +++ b/apps/api/src/billing/routes.csrf.test.ts @@ -0,0 +1,112 @@ +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requestApp } from "../test/http"; + +vi.mock("../auth/better-auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +vi.mock("../auth/middleware", () => ({ + requireAuth: (req: any, _res: any, next: () => void) => { + req.authKind = "session"; + req.userId = "user-1"; + next(); + }, +})); + +vi.mock("../db/client", () => ({ db: {} })); + +import billingRoutes from "./routes"; + +function probeApp() { + const app = express(); + app.use(billingRoutes); + app.use((_req, res) => res.status(204).end()); + return app; +} + +describe("billing origin CSRF boundary", () => { + beforeEach(() => { + process.env.WEB_CLIENT = "http://localhost:3000"; + process.env.API_PUBLIC_URL = "http://localhost:5000"; + }); + + it("lets non-billing mutations fall through to later routers", async () => { + const response = await requestApp(probeApp(), "/contacts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + expect(response.status).toBe(204); + }); + + it("lets organization mutations that are not billing writes fall through", async () => { + const response = await requestApp( + probeApp(), + "/organizations/org_1/teams", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }, + ); + + expect(response.status).toBe(204); + }); + + it("rejects billing mutations without an Origin", async () => { + const response = await requestApp( + probeApp(), + "/billing/organization-checkouts", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }, + ); + + expect(response.status).toBe(403); + expect(response.json()).toEqual({ error: "csrf_origin_invalid" }); + }); + + it("protects billing action-token issuance with the same origin boundary", async () => { + const response = await requestApp(probeApp(), "/billing/action-token", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "checkout", target: "org_1" }), + }); + + expect(response.status).toBe(403); + expect(response.json()).toEqual({ error: "csrf_origin_invalid" }); + }); + + it("rejects billing mutations from a foreign Origin", async () => { + const response = await requestApp( + probeApp(), + "/organizations/org_1/billing/checkout", + { + method: "POST", + headers: { + origin: "https://evil.example", + "content-type": "application/json", + }, + body: "{}", + }, + ); + + expect(response.status).toBe(403); + expect(response.json()).toEqual({ error: "csrf_origin_invalid" }); + }); + + it("does not origin-gate billing reads", async () => { + const response = await requestApp( + probeApp(), + "/organizations/org_1/billing/plan-changes/chg_1", + { method: "GET" }, + ); + + expect(response.status).not.toBe(403); + expect(response.body.includes("csrf_origin_invalid")).toBe(false); + }); +}); diff --git a/apps/api/src/billing/routes.ts b/apps/api/src/billing/routes.ts new file mode 100644 index 0000000..678d328 --- /dev/null +++ b/apps/api/src/billing/routes.ts @@ -0,0 +1,506 @@ +import { and, eq } from "drizzle-orm"; +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { createExpressEndpoints, initServer } from "@ts-rest/express"; +import { contract } from "@sendlit/api-contract"; +import { requireAuth } from "../auth/middleware"; +import { db } from "../db/client"; +import { + billingPlanChangeAttempts, + organizationPlanStates, + organizationSubscriptions, +} from "../db/schema"; +import { readBillingConfig, type BillingOffer } from "./catalog"; +import { checkoutIsAvailable, getActiveCatalog } from "./catalog-store"; + +import { getOrganizationEntitlements } from "./entitlements"; +import { + BillingCheckoutError, + createOrganizationCheckout, + createPaidOrganizationCheckout, +} from "./checkout"; +import { createOrganizationPortal } from "./portal"; +import { + BillingPlanChangeError, + createOrganizationPlanChange, + getOrganizationPlanChange, +} from "./plan-change"; +import { usageForOrganization } from "./usage"; +import { + getOrganizationByPublicId, + getOrganizationMembership, +} from "../organization/queries"; +import { + billingMutationOrigin, + ensureCsrfCookie, + issueBillingActionToken, + requireBillingAction, + type BillingAction, +} from "./security"; + +const router = Router(); +const s = initServer(); +let publicCatalogCache: { + revision: number; + expiresAt: number; + offers: ReturnType["offers"]; +} | null = null; + +const catalogLimiter = rateLimit({ + windowMs: 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: { error: "too_many_requests" }, +}); + +function billingError(error: BillingCheckoutError) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; +} + +function planChangeError(error: BillingPlanChangeError) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; +} + +async function authorizeOrganization(req: any, publicId: string) { + if (!req.userId || !["session", "oauth"].includes(req.authKind)) { + return null; + } + const organization = await getOrganizationByPublicId(publicId); + if (!organization) return null; + const membership = await getOrganizationMembership( + organization.id, + req.userId, + ); + return membership ? { organization, membership } : null; +} + +const impl = s.router(contract.billing, { + actionToken: async ({ + req, + body, + }: { + req: any; + body: { action: BillingAction; target: string }; + }) => { + const result = await issueBillingActionToken( + req, + req.res, + body.action, + body.target, + ); + if ("status" in result) return result as any; + return { status: 201, body: result }; + }, + organizationCheckout: async ({ req, body }: { req: any; body: any }) => { + const boundary = await requireBillingAction( + req, + req.res, + "organization_checkout", + "new", + ); + if (boundary) return boundary as any; + try { + const result = await createPaidOrganizationCheckout({ + payerUserId: req.userId, + organizationName: body.organizationName, + teamName: body.teamName, + plan: body.plan, + interval: body.interval, + catalogRevision: body.catalogRevision, + }); + return { status: 201, body: result }; + } catch (error) { + if (error instanceof BillingCheckoutError) + return billingError(error); + throw error; + } + }, + catalog: async ({ req }: { req: any }) => { + try { + const config = readBillingConfig(); + let offers = config.offers; + let activeRevision = config.catalogRevision; + if (config.deploymentMode === "cloud") { + const cached = + publicCatalogCache && + publicCatalogCache.expiresAt > Date.now() + ? publicCatalogCache + : null; + if (cached) { + offers = cached.offers; + activeRevision = cached.revision; + } else { + const active = await getActiveCatalog(config); + offers = active.items.map(({ catalogKey, price }) => ({ + catalogKey: catalogKey as BillingOffer["catalogKey"], + catalogRevision: active.revision.revision, + plan: price.plan as "pro" | "business", + interval: price.billingInterval as "month" | "year", + currency: price.currency, + amountMinor: price.amountMinor, + provider: price.provider, + providerProductId: price.providerProductId, + trialDays: + config.offers.find( + (offer) => offer.catalogKey === catalogKey, + )?.trialDays ?? 0, + })); + activeRevision = active.revision.revision; + publicCatalogCache = { + revision: active.revision.revision, + expiresAt: Date.now() + 5 * 60 * 1000, + offers, + }; + } + } + const etag = `W/\"billing-${activeRevision ?? "oss"}\"`; + req.res?.setHeader?.("Cache-Control", "public, max-age=300"); + req.res?.setHeader?.("ETag", etag); + if (req.headers?.["if-none-match"] === etag) + return { status: 304, body: undefined } as any; + return { + status: 200, + body: { + catalogRevision: activeRevision, + currency: offers[0]?.currency ?? config.currency, + offers: offers.map( + ({ + catalogKey, + plan, + interval, + currency, + amountMinor, + trialDays, + }) => ({ + catalogKey, + plan, + interval, + currency, + amountMinor, + trialDays, + }), + ), + checkoutAvailable: checkoutIsAvailable( + config, + activeRevision, + ), + }, + }; + } catch { + return { + status: 503, + body: { error: "billing_catalog_unavailable" }, + }; + } + }, + organizationBilling: async ({ req, params }) => { + const authorization = await authorizeOrganization( + req, + params.organizationId, + ); + if (!authorization) { + return { status: 404, body: { error: "organization_not_found" } }; + } + const [planState] = await db + .select() + .from(organizationPlanStates) + .where( + eq( + organizationPlanStates.organizationId, + authorization.organization.id, + ), + ) + .limit(1); + const [state] = planState?.activeSubscriptionId + ? await db + .select() + .from(organizationSubscriptions) + .where( + eq( + organizationSubscriptions.id, + planState.activeSubscriptionId, + ), + ) + .limit(1) + : []; + const [pendingPlanChange] = await db + .select({ + changeId: billingPlanChangeAttempts.changeId, + targetPlan: billingPlanChangeAttempts.targetPlan, + targetInterval: billingPlanChangeAttempts.targetInterval, + effectiveAt: billingPlanChangeAttempts.effectiveAt, + }) + .from(billingPlanChangeAttempts) + .where( + and( + eq( + billingPlanChangeAttempts.organizationId, + authorization.organization.id, + ), + eq(billingPlanChangeAttempts.status, "pending"), + ), + ) + .limit(1); + const entitlements = await getOrganizationEntitlements( + authorization.organization.id, + ); + const usage = await usageForOrganization(authorization.organization.id); + const billingManager = state?.billingManagerUserId ?? null; + return { + status: 200, + body: { + plan: entitlements.plan, + billingInterval: entitlements.interval, + paymentStatus: entitlements.paymentStatus, + trialEndsAt: state?.trialEndsAt?.toISOString() ?? null, + currentPeriodEndsAt: + state?.currentPeriodEndsAt?.toISOString() ?? null, + cancelAtPeriodEnd: state?.cancelAtPeriodEnd ?? false, + graceEndsAt: entitlements.graceEndsAt?.toISOString() ?? null, + canManageBilling: + authorization.membership.role === "owner" && + (!billingManager || billingManager === (req as any).userId), + entitlements: { + teamsLimit: entitlements.teamsLimit, + subscribedContactsLimit: + entitlements.subscribedContactsLimit, + monthlySendsLimit: entitlements.monthlySendsLimit, + sharedOrganizationMailbox: + entitlements.sharedOrganizationMailbox, + provisioning: entitlements.provisioning, + organizationApiKeys: entitlements.organizationApiKeys, + marketingBranding: entitlements.marketingBranding, + }, + usage: { + ...usage, + plan: entitlements.plan, + paymentStatus: entitlements.paymentStatus, + teamsLimit: entitlements.teamsLimit, + subscribedContactsLimit: + entitlements.subscribedContactsLimit, + monthlySendsLimit: entitlements.monthlySendsLimit, + }, + pendingPlanChange: pendingPlanChange + ? { + changeId: pendingPlanChange.changeId, + targetPlan: pendingPlanChange.targetPlan as + "pro" | "business", + targetInterval: pendingPlanChange.targetInterval as + "month" | "year", + effectiveAt: pendingPlanChange.effectiveAt as + "immediately" | "next_billing_date", + } + : null, + }, + }; + }, + organizationPlanUsage: async ({ req, params }) => { + const authorization = await authorizeOrganization( + req, + params.organizationId, + ); + if (!authorization) { + return { status: 404, body: { error: "organization_not_found" } }; + } + const entitlements = await getOrganizationEntitlements( + authorization.organization.id, + ); + return { + status: 200, + body: { + ...(await usageForOrganization(authorization.organization.id)), + plan: entitlements.plan, + paymentStatus: entitlements.paymentStatus, + teamsLimit: entitlements.teamsLimit, + subscribedContactsLimit: entitlements.subscribedContactsLimit, + monthlySendsLimit: entitlements.monthlySendsLimit, + }, + }; + }, + checkout: async ({ req, params, body }) => { + const authorization = await authorizeOrganization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + if (authorization.membership.role !== "owner") + return { status: 403, body: { error: "billing_owner_required" } }; + const boundary = await requireBillingAction( + req, + req.res, + "checkout", + params.organizationId, + ); + if (boundary) return boundary as any; + try { + const result = await createOrganizationCheckout({ + organizationId: authorization.organization.id, + payerUserId: (req as any).userId, + plan: body.plan, + interval: body.interval, + catalogRevision: body.catalogRevision, + }); + return { status: 201, body: result }; + } catch (error) { + if (error instanceof BillingCheckoutError) + return billingError(error); + throw error; + } + }, + portal: async ({ req, params }) => { + const authorization = await authorizeOrganization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + if (authorization.membership.role !== "owner") + return { status: 403, body: { error: "billing_owner_required" } }; + const boundary = await requireBillingAction( + req, + req.res, + "portal", + params.organizationId, + ); + if (boundary) return boundary as any; + try { + const result = await createOrganizationPortal({ + organizationId: authorization.organization.id, + userId: (req as any).userId, + }); + return { status: 201, body: result }; + } catch (error) { + if (error instanceof BillingCheckoutError) + return billingError(error); + throw error; + } + }, + changePlan: async ({ + req, + params, + body, + }: { + req: any; + params: any; + body: any; + }) => { + const authorization = await authorizeOrganization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + const boundary = await requireBillingAction( + req, + req.res, + "plan_change", + params.organizationId, + ); + if (boundary) return boundary as any; + try { + const result = await createOrganizationPlanChange({ + organizationId: authorization.organization.id, + actorUserId: (req as any).userId, + plan: body.plan, + interval: body.interval, + catalogRevision: body.catalogRevision, + idempotencyKey: body.idempotencyKey, + }); + return { + status: result.status === "pending" ? 202 : 200, + body: result, + } as any; + } catch (error) { + if (error instanceof BillingPlanChangeError) + return planChangeError(error); + throw error; + } + }, + getPlanChange: async ({ req, params }: { req: any; params: any }) => { + const authorization = await authorizeOrganization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + const result = await getOrganizationPlanChange({ + organizationId: authorization.organization.id, + changeId: params.changeId, + userId: (req as any).userId, + }); + if (!result) + return { + status: 404, + body: { error: "billing_plan_change_not_found" }, + }; + return { status: 200, body: result }; + }, +}); + +// Register middleware before ts-rest endpoints so auth/privacy checks cannot +// be bypassed by a handler added later. +router.use((req, res, next) => { + if (req.path !== "/billing/catalog") { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Referrer-Policy", "no-referrer"); + } + next(); +}); +router.use("/billing/catalog", catalogLimiter); +router.use( + "/billing/action-token", + rateLimit({ + windowMs: 60_000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + }), +); +router.use( + "/organizations/:organizationId/billing/plan-change", + rateLimit({ + windowMs: 60_000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + }), +); +router.use("/billing/organization-checkouts", requireAuth); +router.use("/billing/action-token", requireAuth); +router.use("/organizations", requireAuth); +router.use((req, res, next) => { + if ((req as any).authKind === "session") ensureCsrfCookie(req, res); + next(); +}); +// This router is mounted at the app root so it can own `/billing/*` and +// `/organizations/:id/billing/*`. Origin CSRF is therefore path-scoped; +// an unscoped mutation gate would 403 contact/team writes with +// `csrf_origin_invalid` before those routers run. +const billingMutationPaths = [ + "/billing/action-token", + "/billing/organization-checkouts", + "/organizations/:organizationId/billing/checkout", + "/organizations/:organizationId/billing/portal", + "/organizations/:organizationId/billing/plan-change", + "/organizations/:organizationId/abandon", +]; +router.use(billingMutationPaths, (req, res, next) => { + if ( + ["POST", "PUT", "PATCH", "DELETE"].includes(req.method) && + !billingMutationOrigin(req) + ) { + return res.status(403).json({ error: "csrf_origin_invalid" }); + } + next(); +}); +createExpressEndpoints(contract.billing, impl, router); + +export default router; diff --git a/apps/api/src/billing/security.test.ts b/apps/api/src/billing/security.test.ts new file mode 100644 index 0000000..ec8b5cb --- /dev/null +++ b/apps/api/src/billing/security.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMocks = vi.hoisted(() => ({ getSession: vi.fn() })); + +vi.mock("../auth/better-auth", () => ({ + auth: { api: { getSession: authMocks.getSession } }, +})); + +vi.mock("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); + +import { db } from "../db/client"; +import { truncateAll, type TestDb } from "../test/db"; +import { issueBillingActionToken, requireBillingAction } from "./security"; + +const tdb = db as unknown as TestDb; + +function request(headers: Record = {}) { + return { + authKind: "session", + userId: "user-1", + headers: { + origin: "http://localhost:3000", + cookie: "sendlit_csrf=csrf-value", + "x-sendlit-csrf": "csrf-value", + ...headers, + }, + }; +} + +const response = { append: vi.fn() }; + +beforeEach(async () => { + process.env.WEB_CLIENT = "http://localhost:3000"; + process.env.API_PUBLIC_URL = "http://localhost:5000"; + process.env.BILLING_RECENT_AUTH_MAX_AGE_SECONDS = "900"; + response.append.mockClear(); + authMocks.getSession.mockReset(); + await truncateAll(tdb); +}); + +describe("billing action authorization", () => { + it("issues a target-bound token that can be consumed only once", async () => { + const createdAt = new Date(); + authMocks.getSession.mockResolvedValue({ + user: { id: "user-1" }, + session: { id: "session-1", createdAt, updatedAt: createdAt }, + }); + const issued = await issueBillingActionToken( + request(), + response, + "checkout", + "org_1", + ); + expect("token" in issued).toBe(true); + if (!("token" in issued)) throw new Error("token_not_issued"); + + const authorizedRequest = request({ + "x-sendlit-billing-action-token": issued.token, + }); + await expect( + requireBillingAction( + authorizedRequest, + response, + "checkout", + "org_1", + ), + ).resolves.toBeNull(); + await expect( + requireBillingAction( + authorizedRequest, + response, + "checkout", + "org_1", + ), + ).resolves.toMatchObject({ + status: 401, + body: { error: "billing_action_token_invalid" }, + }); + }); + + it("does not treat an ordinary session refresh as recent authentication", async () => { + authMocks.getSession.mockResolvedValue({ + user: { id: "user-1" }, + session: { + id: "session-1", + createdAt: new Date(Date.now() - 60 * 60 * 1000), + updatedAt: new Date(), + }, + }); + + await expect( + issueBillingActionToken(request(), response, "checkout", "org_1"), + ).resolves.toEqual({ + status: 401, + body: { error: "recent_authentication_required" }, + }); + }); +}); diff --git a/apps/api/src/billing/security.ts b/apps/api/src/billing/security.ts new file mode 100644 index 0000000..d723ee2 --- /dev/null +++ b/apps/api/src/billing/security.ts @@ -0,0 +1,273 @@ +import { + createHash, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import { and, eq, gt, like, lte } from "drizzle-orm"; +import { fromNodeHeaders } from "better-auth/node"; +import { auth } from "../auth/better-auth"; +import { db } from "../db/client"; +import { verification } from "../db/schema"; + +export type BillingAction = + | "organization_checkout" + | "checkout" + | "portal" + | "plan_change" + | "organization_close" + | "pending_hide"; + +type ActionTokenValue = { + userId: string; + sessionId: string; + action: BillingAction; + target: string; +}; + +export type BillingSecurityFailure = { + status: 401 | 403 | 503; + body: { error: string }; +}; + +export const billingActions: readonly BillingAction[] = [ + "organization_checkout", + "checkout", + "portal", + "plan_change", + "organization_close", + "pending_hide", +] as const; + +export function billingMutationOrigin(req: any): boolean { + if (!req.userId || req.authKind !== "session") return false; + const originHeader = + typeof req.headers.origin === "string" ? req.headers.origin : null; + const refererHeader = + typeof req.headers.referer === "string" ? req.headers.referer : null; + let origin = originHeader; + if (!origin && refererHeader) { + try { + origin = new URL(refererHeader).origin; + } catch { + origin = null; + } + } + if (!origin) return false; + const allowed = new Set(); + for (const value of [process.env.API_PUBLIC_URL, process.env.WEB_CLIENT]) { + if (!value) continue; + try { + allowed.add(new URL(value).origin); + } catch { + // Startup configuration validation reports malformed URLs. + } + } + return allowed.has(origin); +} + +function cookieValue(req: any, name: string): string | null { + const header = + typeof req.headers?.cookie === "string" ? req.headers.cookie : ""; + for (const part of header.split(";")) { + const [key, ...value] = part.trim().split("="); + if (key === name) return value.join("=") || null; + } + return null; +} + +export function ensureCsrfCookie(req: any, res: any): string { + const existing = cookieValue(req, "sendlit_csrf"); + if (existing) return existing; + const token = randomBytes(32).toString("base64url"); + const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; + res.append( + "Set-Cookie", + `sendlit_csrf=${token}; Path=/; SameSite=Lax${secure}`, + ); + return token; +} + +function csrfTokenMatches(req: any, res: any): boolean { + const cookie = cookieValue(req, "sendlit_csrf"); + const header = + typeof req.headers?.["x-sendlit-csrf"] === "string" + ? req.headers["x-sendlit-csrf"] + : ""; + if (!cookie || !header) { + ensureCsrfCookie(req, res); + return false; + } + const left = Buffer.from(cookie); + const right = Buffer.from(header); + return left.length === right.length && timingSafeEqual(left, right); +} + +function tokenHash(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +function validTarget(target: unknown): target is string { + return ( + typeof target === "string" && target.length > 0 && target.length <= 300 + ); +} + +async function sessionContext(req: any) { + const current = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + if (!current?.session || current.user.id !== req.userId) return null; + return current; +} + +function commonBoundary(req: any, res: any): BillingSecurityFailure | null { + if (!req.userId || req.authKind !== "session") { + return { + status: 403, + body: { error: "billing_human_session_required" }, + }; + } + if (!billingMutationOrigin(req)) { + return { status: 403, body: { error: "csrf_origin_invalid" } }; + } + if (!csrfTokenMatches(req, res)) { + return { status: 403, body: { error: "csrf_token_invalid" } }; + } + return null; +} + +export async function issueBillingActionToken( + req: any, + res: any, + action: BillingAction, + target: string, +): Promise { + const boundary = commonBoundary(req, res); + if (boundary) return boundary; + if (!validTarget(target)) { + return { status: 403, body: { error: "billing_action_invalid" } }; + } + try { + const current = await sessionContext(req); + if (!current) { + return { + status: 401, + body: { error: "recent_authentication_required" }, + }; + } + const authenticatedAt = new Date(current.session.createdAt).getTime(); + const maxAgeSeconds = Number( + process.env.BILLING_RECENT_AUTH_MAX_AGE_SECONDS ?? 900, + ); + if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds <= 0) { + return { + status: 503, + body: { error: "billing_provider_unavailable" }, + }; + } + if ( + !Number.isFinite(authenticatedAt) || + Date.now() - authenticatedAt > maxAgeSeconds * 1000 + ) { + return { + status: 401, + body: { error: "recent_authentication_required" }, + }; + } + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + const value: ActionTokenValue = { + userId: req.userId, + sessionId: current.session.id, + action, + target, + }; + await db.transaction(async (tx) => { + await tx + .delete(verification) + .where( + and( + like(verification.identifier, "billing-action:%"), + lte(verification.expiresAt, new Date()), + ), + ); + await tx.insert(verification).values({ + id: randomUUID(), + identifier: `billing-action:${tokenHash(token)}`, + value: JSON.stringify(value), + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + return { token, expiresAt: expiresAt.toISOString() }; + } catch { + return { status: 503, body: { error: "billing_security_unavailable" } }; + } +} + +export async function requireBillingAction( + req: any, + res: any, + action: BillingAction, + target: string, +): Promise { + const boundary = commonBoundary(req, res); + if (boundary) return boundary; + const token = + typeof req.headers?.["x-sendlit-billing-action-token"] === "string" + ? req.headers["x-sendlit-billing-action-token"] + : ""; + if (!token || !validTarget(target)) { + return { + status: 401, + body: { error: "billing_action_token_required" }, + }; + } + try { + const current = await sessionContext(req); + if (!current) { + return { + status: 401, + body: { error: "recent_authentication_required" }, + }; + } + const identifier = `billing-action:${tokenHash(token)}`; + const consumed = await db.transaction(async (tx) => { + const [row] = await tx + .select() + .from(verification) + .where( + and( + eq(verification.identifier, identifier), + gt(verification.expiresAt, new Date()), + ), + ) + .limit(1) + .for("update"); + if (!row) return false; + let value: ActionTokenValue; + try { + value = JSON.parse(row.value) as ActionTokenValue; + } catch { + return false; + } + if ( + value.userId !== req.userId || + value.sessionId !== current.session.id || + value.action !== action || + value.target !== target + ) { + return false; + } + await tx.delete(verification).where(eq(verification.id, row.id)); + return true; + }); + return consumed + ? null + : { status: 401, body: { error: "billing_action_token_invalid" } }; + } catch { + return { status: 503, body: { error: "billing_security_unavailable" } }; + } +} diff --git a/apps/api/src/billing/usage.ts b/apps/api/src/billing/usage.ts new file mode 100644 index 0000000..4bb4cca --- /dev/null +++ b/apps/api/src/billing/usage.ts @@ -0,0 +1,75 @@ +import { and, count, eq, gte, lt, sql, sum } from "drizzle-orm"; +import { db } from "../db/client"; +import { + contacts, + outboundMessages, + planSendUsageBuckets, + teams, +} from "../db/schema"; + +function monthBounds(now = new Date()) { + const start = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + ); + const end = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1), + ); + return { start, end }; +} + +export async function usageForOrganization( + organizationId: string, + now = new Date(), +) { + const { start, end } = monthBounds(now); + const [[teamCount], [contactCount], [sendCount], [bucket]] = + await Promise.all([ + db + .select({ value: count() }) + .from(teams) + .where( + and( + eq(teams.organizationId, organizationId), + sql`${teams.status} IN ('active', 'sending_suspended')`, + ), + ), + db + .select({ value: count() }) + .from(contacts) + .innerJoin(teams, eq(teams.id, contacts.teamId)) + .where( + and( + eq(teams.organizationId, organizationId), + eq(contacts.subscribed, true), + ), + ), + db + .select({ value: count() }) + .from(outboundMessages) + .innerJoin(teams, eq(teams.id, outboundMessages.teamId)) + .where( + and( + eq(teams.organizationId, organizationId), + gte(outboundMessages.acceptedAt, start), + lt(outboundMessages.acceptedAt, end), + ), + ), + db + .select({ reserved: sum(planSendUsageBuckets.reserved) }) + .from(planSendUsageBuckets) + .where( + and( + eq(planSendUsageBuckets.organizationId, organizationId), + eq(planSendUsageBuckets.bucketMonth, start), + ), + ), + ]); + return { + teams: Number(teamCount?.value ?? 0), + subscribedContacts: Number(contactCount?.value ?? 0), + monthlySends: Number(sendCount?.value ?? 0), + monthlySendsReserved: Number(bucket?.reserved ?? 0), + bucketStartsAt: start.toISOString(), + bucketEndsAt: end.toISOString(), + }; +} diff --git a/apps/api/src/billing/webhook-retry.test.ts b/apps/api/src/billing/webhook-retry.test.ts new file mode 100644 index 0000000..4b096ac --- /dev/null +++ b/apps/api/src/billing/webhook-retry.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + BILLING_WEBHOOK_MAX_ATTEMPTS, + billingWebhookRetry, +} from "./webhook-retry"; + +describe("billing webhook retry schedule", () => { + it("uses the documented delays before quarantining on the eighth attempt", () => { + expect(billingWebhookRetry(1)).toEqual({ + status: "failed", + delayMs: 60 * 1000, + }); + expect(billingWebhookRetry(2)).toEqual({ + status: "failed", + delayMs: 5 * 60 * 1000, + }); + expect(billingWebhookRetry(3)).toEqual({ + status: "failed", + delayMs: 30 * 60 * 1000, + }); + expect(billingWebhookRetry(4)).toEqual({ + status: "failed", + delayMs: 2 * 60 * 60 * 1000, + }); + expect(billingWebhookRetry(5, 0)).toEqual({ + status: "failed", + delayMs: 8 * 60 * 60 * 1000, + }); + expect(billingWebhookRetry(7, 12_000)).toEqual({ + status: "failed", + delayMs: 8 * 60 * 60 * 1000 + 12_000, + }); + expect(billingWebhookRetry(BILLING_WEBHOOK_MAX_ATTEMPTS)).toEqual({ + status: "quarantined", + }); + expect(billingWebhookRetry(BILLING_WEBHOOK_MAX_ATTEMPTS + 1)).toEqual({ + status: "quarantined", + }); + }); +}); diff --git a/apps/api/src/billing/webhook-retry.ts b/apps/api/src/billing/webhook-retry.ts new file mode 100644 index 0000000..6947ac2 --- /dev/null +++ b/apps/api/src/billing/webhook-retry.ts @@ -0,0 +1,36 @@ +import { randomInt } from "node:crypto"; + +/** Total claim/process attempts before a durable webhook is quarantined. */ +export const BILLING_WEBHOOK_MAX_ATTEMPTS = 8; + +const INITIAL_RETRY_DELAYS_MS = [ + 60 * 1000, + 5 * 60 * 1000, + 30 * 60 * 1000, + 2 * 60 * 60 * 1000, +] as const; + +const TAIL_RETRY_DELAY_MS = 8 * 60 * 60 * 1000; +const TAIL_RETRY_JITTER_MS = Math.floor(TAIL_RETRY_DELAY_MS * 0.1); + +export type BillingWebhookRetry = + { status: "quarantined" } | { status: "failed"; delayMs: number }; + +/** Retry schedule from the billing PRD: 1m, 5m, 30m, 2h, then 8h with jitter, + * up to eight attempts. `processingAttempts` is the count after the claim + * increment for the attempt that just failed. */ +export function billingWebhookRetry( + processingAttempts: number, + jitterMs?: number, +): BillingWebhookRetry { + if (processingAttempts >= BILLING_WEBHOOK_MAX_ATTEMPTS) { + return { status: "quarantined" }; + } + const index = Math.max(0, processingAttempts - 1); + if (index < INITIAL_RETRY_DELAYS_MS.length) { + return { status: "failed", delayMs: INITIAL_RETRY_DELAYS_MS[index] }; + } + const jitter = + jitterMs ?? randomInt(-TAIL_RETRY_JITTER_MS, TAIL_RETRY_JITTER_MS + 1); + return { status: "failed", delayMs: TAIL_RETRY_DELAY_MS + jitter }; +} diff --git a/apps/api/src/billing/webhooks/processor.test.ts b/apps/api/src/billing/webhooks/processor.test.ts new file mode 100644 index 0000000..a12d54e --- /dev/null +++ b/apps/api/src/billing/webhooks/processor.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const providerMocks = vi.hoisted(() => ({ + retrieveSubscription: vi.fn(), + parseWebhook: vi.fn(), +})); + +vi.mock("../../db/client", async () => { + const { makeTestDb } = await import("../../test/db.js"); + return { db: await makeTestDb() }; +}); + +vi.mock("../provider-registry", () => ({ + getBillingProvider: () => ({ + provider: "dodo", + retrieveSubscription: providerMocks.retrieveSubscription, + parseWebhook: providerMocks.parseWebhook, + }), +})); + +import { eq } from "drizzle-orm"; +import { db } from "../../db/client"; +import { billingWebhookEvents } from "../../db/schema"; +import { truncateAll, type TestDb } from "../../test/db"; +import { encryptBillingValue } from "../crypto"; +import { BillingProviderError } from "../provider"; +import { + claimBillingWebhookEvent, + processBillingWebhookInboxEvent, +} from "./processor"; + +const tdb = db as unknown as TestDb; +const originalEncryptionKey = process.env.BILLING_DATA_ENCRYPTION_KEY; + +beforeEach(async () => { + process.env.BILLING_DATA_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString( + "base64", + ); + providerMocks.retrieveSubscription.mockReset(); + providerMocks.parseWebhook.mockReset(); + await truncateAll(tdb); +}); + +afterEach(() => { + if (originalEncryptionKey === undefined) { + delete process.env.BILLING_DATA_ENCRYPTION_KEY; + } else { + process.env.BILLING_DATA_ENCRYPTION_KEY = originalEncryptionKey; + } +}); + +describe("durable billing webhook inbox", () => { + it("keeps a verified event for retry when provider retrieval is unavailable", async () => { + const [stored] = await tdb + .insert(billingWebhookEvents) + .values({ + provider: "dodo", + providerEventId: "evt_retry", + eventType: "subscription.updated", + occurredAt: new Date("2026-08-29T00:00:00.000Z"), + payloadEncrypted: encryptBillingValue( + JSON.stringify({ + body: "{}", + headers: {}, + canonical: { + provider: "dodo", + providerEventId: "evt_retry", + eventType: "subscription.updated", + occurredAt: "2026-08-29T00:00:00.000Z", + subscriptionId: "sub_provider", + }, + }), + ), + payloadKeyVersion: "v1", + status: "pending", + }) + .returning({ id: billingWebhookEvents.id }); + providerMocks.retrieveSubscription.mockRejectedValue( + new BillingProviderError("unavailable", "dodo_down"), + ); + + expect(await claimBillingWebhookEvent(stored.id)).toBe(true); + await processBillingWebhookInboxEvent(stored.id); + + const [row] = await tdb + .select() + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.id, stored.id)); + expect(row.status).toBe("failed"); + expect(row.lastError).toBe("provider_unavailable"); + expect(row.processedAt).toBeNull(); + expect(row.availableAt.getTime()).toBeGreaterThan(Date.now() + 30_000); + }); +}); diff --git a/apps/api/src/billing/webhooks/processor.ts b/apps/api/src/billing/webhooks/processor.ts new file mode 100644 index 0000000..00eea8e --- /dev/null +++ b/apps/api/src/billing/webhooks/processor.ts @@ -0,0 +1,697 @@ +import { and, eq, inArray, lte, lt, or, sql } from "drizzle-orm"; +import { db } from "../../db/client"; +import { + billingCheckoutAttempts, + billingPlanChangeAttempts, + billingPriceEntries, + billingProviderCustomers, + billingTrialClaims, + billingWebhookEvents, + organizationAuditEvents, + organizationPlanStates, + organizationSubscriptions, + organizations, + settings, + teamDeliverySettings, + teamMembers, + teams, +} from "../../db/schema"; +import { providerErrorSummary, type CanonicalBillingEvent } from "../provider"; +import { decryptBillingValue } from "../crypto"; +import { getBillingProvider } from "../provider-registry"; +import { billingWebhookRetry } from "../webhook-retry"; +import { defaultTeamName } from "../../organization/default-team-name"; +import logger from "../../services/log"; +import { notifyPaymentPastDue } from "../notifications"; +import { pageBillingAlert } from "../alerts"; + +const paidStatuses = new Set(["active", "trialing", "past_due"]); + +const allowedTransitions: Record> = { + pending: new Set(["pending", "trialing", "active", "cancelled", "expired"]), + trialing: new Set([ + "trialing", + "active", + "past_due", + "cancelled", + "expired", + ]), + active: new Set(["active", "past_due", "cancelled", "expired"]), + past_due: new Set(["past_due", "active", "cancelled", "expired"]), + // A cancellation can be reversed before the paid-through deadline. Dodo + // reports that as active (or trialing/past_due), so recovery must be a + // valid transition rather than being quarantined as stale state. + cancelled: new Set([ + "cancelled", + "trialing", + "active", + "past_due", + "expired", + ]), + expired: new Set(["expired"]), +}; + +function transitionAllowed(previous: string, next: string): boolean { + return allowedTransitions[previous]?.has(next) ?? false; +} + +/** Apply a provider snapshot atomically. Events are merely wake-up signals; + * the Dodo adapter has already retrieved the current subscription state. */ +export async function applyCanonicalBillingEvent( + event: CanonicalBillingEvent, +): Promise { + if (!event.snapshot || !event.subscriptionId) return; + const snapshot = event.snapshot; + const pastDueNotice: Array<{ + organizationId: string; + graceEndsAt: Date | null; + }> = []; + await db.transaction(async (tx) => { + const [price] = await tx + .select() + .from(billingPriceEntries) + .where( + and( + eq(billingPriceEntries.provider, event.provider), + eq( + billingPriceEntries.providerProductId, + snapshot.providerProductId, + ), + ), + ) + .limit(1); + if (!price) throw new Error("billing_unknown_provider_product"); + + const [existing] = await tx + .select() + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.provider, event.provider), + eq( + organizationSubscriptions.providerSubscriptionId, + snapshot.providerSubscriptionId, + ), + ), + ) + .limit(1) + .for("update"); + let attempt = null as + typeof billingCheckoutAttempts.$inferSelect | null; + if (!existing && snapshot.metadata.sendlitCheckoutAttemptId) { + const [row] = await tx + .select() + .from(billingCheckoutAttempts) + .where( + eq( + billingCheckoutAttempts.attemptId, + snapshot.metadata.sendlitCheckoutAttemptId, + ), + ) + .limit(1) + .for("update"); + attempt = row ?? null; + } + const organizationId = + existing?.organizationId ?? attempt?.organizationId; + const billingCustomerId = + existing?.billingCustomerId ?? attempt?.billingCustomerId; + const billingManagerUserId = + existing?.billingManagerUserId ?? attempt?.payerUserId; + if (!organizationId || !billingCustomerId || !billingManagerUserId) { + throw new Error("billing_subscription_unmatched"); + } + const [organization] = await tx + .select({ + id: organizations.id, + name: organizations.name, + status: organizations.status, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1) + .for("update"); + if (!organization) throw new Error("billing_organization_missing"); + if ( + organization.status === "abandoned" || + organization.status === "closed" + ) { + if (attempt) { + await tx + .update(billingCheckoutAttempts) + .set({ + status: "conflicted", + lastError: "billing_organization_not_activatable", + updatedAt: new Date(), + }) + .where(eq(billingCheckoutAttempts.id, attempt.id)); + } + throw new Error("billing_organization_not_activatable"); + } + const [customer] = await tx + .select({ + id: billingProviderCustomers.id, + providerCustomerId: billingProviderCustomers.providerCustomerId, + }) + .from(billingProviderCustomers) + .where(eq(billingProviderCustomers.id, billingCustomerId)) + .limit(1); + if ( + !customer || + customer.providerCustomerId !== snapshot.providerCustomerId + ) { + throw new Error("billing_customer_mismatch"); + } + const [state] = await tx + .select() + .from(organizationPlanStates) + .where(eq(organizationPlanStates.organizationId, organizationId)) + .limit(1) + .for("update"); + if (!state) throw new Error("organization_plan_state_missing"); + // Ordering follows the authoritative subscription snapshot. Dodo + // webhook timestamps can be delayed, while retrieveSubscription gives + // the current state and timestamp used for this projection. + const providerOccurredAt = snapshot.occurredAt; + if ( + existing?.lastProviderEventAt && + existing.lastProviderEventAt > providerOccurredAt + ) + return; + if (existing && !transitionAllowed(existing.status, snapshot.status)) { + throw new Error("billing_invalid_subscription_transition"); + } + const catalogKey = price.catalogKey; + const now = new Date(); + const retainsPaidEntitlement = + paidStatuses.has(snapshot.status) || + (snapshot.status === "cancelled" && + snapshot.cancelAtPeriodEnd && + Boolean( + snapshot.paidThroughAt && + snapshot.paidThroughAt.getTime() > now.getTime(), + )); + const graceEndsAt = + snapshot.status === "past_due" + ? existing?.status === "past_due" && existing.graceEndsAt + ? existing.graceEndsAt + : new Date( + providerOccurredAt.getTime() + + 7 * 24 * 60 * 60 * 1000, + ) + : null; + if (snapshot.status === "past_due" && existing?.status !== "past_due") { + pastDueNotice.push({ organizationId, graceEndsAt }); + } + const pastDueAt = + snapshot.status === "past_due" + ? existing?.status === "past_due" && existing.pastDueAt + ? existing.pastDueAt + : providerOccurredAt + : null; + const isCurrentSubscription = + !state.activeSubscriptionId || + state.activeSubscriptionId === existing?.id; + if (retainsPaidEntitlement && !isCurrentSubscription) { + throw new Error("billing_conflicting_subscription_source"); + } + // Historical subscriptions may continue to emit terminal events. They + // are recorded above, but must never clear or replace the organization's + // current entitlement projection. + const shouldProject = + isCurrentSubscription && + (retainsPaidEntitlement || + state.activeSubscriptionId === existing?.id); + const values = { + organizationId, + billingCustomerId, + billingManagerUserId, + provider: event.provider, + providerSubscriptionId: snapshot.providerSubscriptionId, + providerProductId: snapshot.providerProductId, + billingPriceEntryId: price.id, + catalogKey, + plan: price.plan, + billingInterval: price.billingInterval, + status: snapshot.status, + currentPeriodStartsAt: snapshot.currentPeriodStartsAt, + currentPeriodEndsAt: snapshot.currentPeriodEndsAt, + paidThroughAt: snapshot.paidThroughAt, + trialEndsAt: snapshot.trialEndsAt, + pastDueAt, + graceEndsAt, + cancelAtPeriodEnd: snapshot.cancelAtPeriodEnd, + isEntitlementSource: shouldProject && retainsPaidEntitlement, + lastProviderEventAt: providerOccurredAt, + lastReconciledAt: new Date(), + updatedAt: new Date(), + } as const; + const [subscription] = existing + ? await tx + .update(organizationSubscriptions) + .set(values) + .where(eq(organizationSubscriptions.id, existing.id)) + .returning() + : await tx + .insert(organizationSubscriptions) + .values(values) + .returning(); + if (!subscription) + throw new Error("billing_subscription_projection_failed"); + // Only one subscription can grant entitlements. A newly active one + // supersedes a prior terminal/old source; conflicting active sources + // are rejected by the database partial unique index. + if (shouldProject) { + await tx + .update(organizationSubscriptions) + .set({ isEntitlementSource: false, updatedAt: new Date() }) + .where( + and( + eq( + organizationSubscriptions.organizationId, + organizationId, + ), + eq(organizationSubscriptions.isEntitlementSource, true), + ), + ); + if (retainsPaidEntitlement) { + await tx + .update(organizationSubscriptions) + .set({ isEntitlementSource: true, updatedAt: new Date() }) + .where(eq(organizationSubscriptions.id, subscription.id)); + } + } + const active = shouldProject && retainsPaidEntitlement; + const nextPlan = active ? (price.plan as "pro" | "business") : "free"; + const nextSubscriptionId = active ? subscription.id : null; + if (shouldProject) { + await tx + .update(organizationPlanStates) + .set({ + plan: nextPlan, + activeSubscriptionId: nextSubscriptionId, + firstPaidActivatedAt: + active && !state.firstPaidActivatedAt + ? providerOccurredAt + : state.firstPaidActivatedAt, + projectionVersion: state.projectionVersion + 1, + updatedAt: new Date(), + }) + .where(eq(organizationPlanStates.id, state.id)); + if ( + state.plan !== nextPlan || + state.activeSubscriptionId !== nextSubscriptionId + ) { + await tx.insert(organizationAuditEvents).values({ + organizationId, + actorType: "system", + action: "billing.plan_projection_changed", + metadata: { + previousPlan: state.plan, + nextPlan, + previousSubscriptionId: state.activeSubscriptionId, + nextSubscriptionId, + provider: event.provider, + }, + }); + } + } + // A plan-change attempt is completed only when the signed provider + // snapshot shows the requested product on the same subscription. This + // keeps entitlements and the mutation status on one source of truth. + if (existing && shouldProject && retainsPaidEntitlement) { + await tx + .update(billingPlanChangeAttempts) + .set({ + status: "succeeded", + completedAt: new Date(), + paymentUrlEncrypted: null, + lastError: null, + updatedAt: new Date(), + }) + .where( + and( + eq( + billingPlanChangeAttempts.subscriptionId, + subscription.id, + ), + eq(billingPlanChangeAttempts.provider, event.provider), + eq( + billingPlanChangeAttempts.targetBillingPriceEntryId, + price.id, + ), + inArray(billingPlanChangeAttempts.status, [ + "creating", + "pending", + ]), + ), + ); + } + if (attempt && active) { + await tx + .update(billingCheckoutAttempts) + .set({ + status: "completed", + completedAt: new Date(), + updatedAt: new Date(), + checkoutUrlEncrypted: null, + }) + .where(eq(billingCheckoutAttempts.id, attempt.id)); + await tx + .update(billingTrialClaims) + .set({ + status: "redeemed", + redeemedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(billingTrialClaims.checkoutAttemptId, attempt.id)); + const [existingTeam] = await tx + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.organizationId, organizationId)) + .limit(1); + if (!existingTeam) { + const [team] = await tx + .insert(teams) + .values({ + organizationId, + name: + attempt.pendingTeamName || + defaultTeamName(organization.name), + }) + .returning(); + await tx.insert(settings).values({ teamId: team.id }); + await tx + .insert(teamDeliverySettings) + .values({ teamId: team.id }); + await tx.insert(teamMembers).values({ + teamId: team.id, + userId: attempt.payerUserId, + role: "admin", + }); + } + } + if (shouldProject) { + await tx + .update(organizations) + .set({ + // A paid-org activation makes the pending row selectable. + // A cancellation/expiry returns it to ordinary Free + // operation; it must not strand the organization in a + // suspended state. + status: + organization.status === "pending_payment" && !active + ? "pending_payment" + : "active", + updatedAt: new Date(), + }) + .where(eq(organizations.id, organizationId)); + } + }); + if (pastDueNotice[0]) { + await notifyPaymentPastDue( + pastDueNotice[0].organizationId, + pastDueNotice[0].graceEndsAt, + ).catch(() => undefined); + } +} + +/** Project scheduled cancellations to Free once their verified paid-through + * deadline has passed. This runs under the organization and subscription + * locks, so a simultaneous resume webhook cannot be lost. */ +export async function expireCancelledSubscriptionEntitlements( + now = new Date(), +): Promise { + const due = await db + .select({ + id: organizationSubscriptions.id, + organizationId: organizationSubscriptions.organizationId, + }) + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.status, "cancelled"), + eq(organizationSubscriptions.isEntitlementSource, true), + lte(organizationSubscriptions.paidThroughAt, now), + ), + ) + .limit(500); + let expired = 0; + for (const row of due) { + const applied = await db.transaction(async (tx) => { + const [subscription] = await tx + .select() + .from(organizationSubscriptions) + .where(eq(organizationSubscriptions.id, row.id)) + .limit(1) + .for("update"); + if ( + !subscription || + subscription.status !== "cancelled" || + !subscription.isEntitlementSource || + !subscription.paidThroughAt || + subscription.paidThroughAt > now + ) + return false; + await tx + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.id, row.organizationId)) + .limit(1) + .for("update"); + const [state] = await tx + .select() + .from(organizationPlanStates) + .where( + eq( + organizationPlanStates.organizationId, + row.organizationId, + ), + ) + .limit(1) + .for("update"); + if (!state || state.activeSubscriptionId !== subscription.id) + return false; + await tx + .update(organizationSubscriptions) + .set({ isEntitlementSource: false, updatedAt: now }) + .where(eq(organizationSubscriptions.id, subscription.id)); + await tx + .update(organizationPlanStates) + .set({ + plan: "free", + activeSubscriptionId: null, + projectionVersion: state.projectionVersion + 1, + updatedAt: now, + }) + .where(eq(organizationPlanStates.id, state.id)); + await tx.insert(organizationAuditEvents).values({ + organizationId: row.organizationId, + actorType: "system", + action: "billing.plan_expired", + metadata: { + subscriptionId: subscription.id, + paidThroughAt: subscription.paidThroughAt.toISOString(), + }, + }); + return true; + }); + if (applied) expired += 1; + } + return expired; +} + +/** Claim one durable billing inbox row. Expired leases are reclaimed after a + * worker crash; retry attempts are bounded before quarantine. */ +export async function claimBillingWebhookEvent( + eventId: string, + now = new Date(), +): Promise { + const lease = new Date(now.getTime() + 5 * 60 * 1000); + const [claimed] = await db + .update(billingWebhookEvents) + .set({ + status: "processing", + processingAttempts: sql`${billingWebhookEvents.processingAttempts} + 1`, + lockedAt: now, + leaseExpiresAt: lease, + workerId: `billing-${process.pid}`, + }) + .where( + and( + eq(billingWebhookEvents.id, eventId), + or( + eq(billingWebhookEvents.status, "pending"), + and( + eq(billingWebhookEvents.status, "failed"), + lte(billingWebhookEvents.availableAt, now), + ), + and( + eq(billingWebhookEvents.status, "processing"), + lt(billingWebhookEvents.leaseExpiresAt, now), + ), + ), + ), + ) + .returning({ id: billingWebhookEvents.id }); + return Boolean(claimed); +} + +export async function processBillingWebhookInboxEvent( + eventId: string, + parsedEvent?: CanonicalBillingEvent, +): Promise { + try { + const [row] = await db + .select() + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.id, eventId)) + .limit(1); + if (!row) return; + let event = parsedEvent; + const provider = getBillingProvider(row.provider); + if (!event) { + if (!row.payloadEncrypted) + throw new Error("billing_webhook_payload_missing"); + const encrypted = decryptBillingValue(row.payloadEncrypted); + let body = encrypted; + let headers: Record = {}; + try { + const envelope = JSON.parse(encrypted) as { + body?: unknown; + headers?: unknown; + canonical?: { + provider: string; + providerEventId: string; + eventType: string; + occurredAt: string; + subscriptionId?: string; + snapshot?: Record; + }; + }; + if (envelope.canonical) { + const canonical = envelope.canonical; + const rawSnapshot = canonical.snapshot; + event = { + provider: canonical.provider, + providerEventId: canonical.providerEventId, + eventType: canonical.eventType, + occurredAt: new Date(canonical.occurredAt), + subscriptionId: canonical.subscriptionId, + rawPayload: null, + snapshot: rawSnapshot + ? ({ + ...(rawSnapshot as any), + currentPeriodStartsAt: + rawSnapshot.currentPeriodStartsAt + ? new Date( + String( + rawSnapshot.currentPeriodStartsAt, + ), + ) + : null, + currentPeriodEndsAt: + rawSnapshot.currentPeriodEndsAt + ? new Date( + String( + rawSnapshot.currentPeriodEndsAt, + ), + ) + : null, + paidThroughAt: rawSnapshot.paidThroughAt + ? new Date( + String(rawSnapshot.paidThroughAt), + ) + : null, + trialEndsAt: rawSnapshot.trialEndsAt + ? new Date( + String(rawSnapshot.trialEndsAt), + ) + : null, + occurredAt: rawSnapshot.occurredAt + ? new Date(String(rawSnapshot.occurredAt)) + : new Date(canonical.occurredAt), + } as CanonicalBillingEvent["snapshot"]) + : undefined, + }; + } + if (typeof envelope.body === "string") { + body = envelope.body; + if ( + envelope.headers && + typeof envelope.headers === "object" + ) { + headers = envelope.headers as Record; + } + } + } catch { + // Rows written before header-envelope storage retain the raw + // body; they remain available for operator replay. + } + if (!event) { + event = await provider.parseWebhook({ + body, + headers, + }); + } + } + // Signed webhooks are durable wake-up signals. Always project the + // provider's current subscription snapshot, including for a replayed + // canonical envelope, so delayed events cannot roll state backwards. + if (event.subscriptionId) { + event.snapshot = await provider.retrieveSubscription( + event.subscriptionId, + ); + } + await applyCanonicalBillingEvent(event); + await db + .update(billingWebhookEvents) + .set({ + status: "processed", + processedAt: new Date(), + leaseExpiresAt: null, + }) + .where(eq(billingWebhookEvents.id, eventId)); + } catch (error) { + const [row] = await db + .select({ + processingAttempts: billingWebhookEvents.processingAttempts, + }) + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.id, eventId)) + .limit(1); + const attempts = Number(row?.processingAttempts ?? 1); + const retry = billingWebhookRetry(attempts); + const terminal = retry.status === "quarantined"; + await db + .update(billingWebhookEvents) + .set({ + status: terminal ? "quarantined" : "failed", + lastError: providerErrorSummary(error), + ...(retry.status === "failed" + ? { availableAt: new Date(Date.now() + retry.delayMs) } + : {}), + leaseExpiresAt: null, + }) + .where(eq(billingWebhookEvents.id, eventId)); + logger[terminal ? "error" : "warn"]( + { + billing_webhook_event_id: eventId, + processing_attempts: attempts, + error: providerErrorSummary(error), + }, + terminal + ? "billing webhook quarantined" + : "billing webhook retry scheduled", + ); + if (terminal) { + await pageBillingAlert({ + code: "webhook_quarantined", + message: + "A billing webhook event is quarantined and needs operator review.", + details: { count: 1 }, + }).catch(() => undefined); + throw error; + } + } +} diff --git a/apps/api/src/billing/webhooks/routes.ts b/apps/api/src/billing/webhooks/routes.ts new file mode 100644 index 0000000..7d3a3fc --- /dev/null +++ b/apps/api/src/billing/webhooks/routes.ts @@ -0,0 +1,130 @@ +import express, { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { db } from "../../db/client"; +import { billingWebhookEvents } from "../../db/schema"; +import { encryptBillingValue } from "../crypto"; +import { getBillingProvider } from "../provider-registry"; +import { + claimBillingWebhookEvent, + processBillingWebhookInboxEvent, +} from "./processor"; +import type { CanonicalBillingEvent } from "../provider"; +import { pageBillingAlert, recordWebhookSignatureFailure } from "../alerts"; + +const router = Router(); + +function serializeCanonicalEvent(event: CanonicalBillingEvent) { + const snapshot = event.snapshot + ? { + ...event.snapshot, + occurredAt: event.snapshot.occurredAt.toISOString(), + currentPeriodStartsAt: + event.snapshot.currentPeriodStartsAt?.toISOString() ?? null, + currentPeriodEndsAt: + event.snapshot.currentPeriodEndsAt?.toISOString() ?? null, + paidThroughAt: + event.snapshot.paidThroughAt?.toISOString() ?? null, + trialEndsAt: event.snapshot.trialEndsAt?.toISOString() ?? null, + } + : undefined; + return { + provider: event.provider, + providerEventId: event.providerEventId, + eventType: event.eventType, + occurredAt: event.occurredAt.toISOString(), + subscriptionId: event.subscriptionId, + snapshot, + }; +} +const limiter = rateLimit({ + windowMs: 60_000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.ip || "unknown", +}); + +/** Provider webhook ingress must precede express.json() in index.ts. */ +router.post( + "/webhooks/billing/dodo", + limiter, + express.raw({ type: "application/json", limit: "256kb" }), + async (req, res) => { + const body = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; + if (!body) + return res.status(400).json({ error: "webhook_body_required" }); + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers[key.toLowerCase()] = value; + else if (Array.isArray(value)) + headers[key.toLowerCase()] = value[0] ?? ""; + } + let event; + let provider; + try { + provider = getBillingProvider("dodo"); + event = await provider.parseWebhook({ body, headers }); + } catch { + const count = recordWebhookSignatureFailure(); + if (count >= 10) { + void pageBillingAlert({ + code: "webhook_signature_spike", + message: "Billing webhook signature failures are spiking.", + details: { count }, + }).catch(() => undefined); + } + return res.status(400).json({ error: "webhook_signature_invalid" }); + } + try { + const [stored] = await db + .insert(billingWebhookEvents) + .values({ + provider: event.provider, + providerEventId: event.providerEventId, + eventType: event.eventType, + occurredAt: event.occurredAt, + // Keep the verified raw headers with the encrypted body so + // a later inbox worker can re-verify a replay after the + // request process has exited. + payloadEncrypted: encryptBillingValue( + JSON.stringify({ + body, + headers, + canonical: serializeCanonicalEvent(event), + }), + ), + payloadKeyVersion: + process.env.BILLING_DATA_ENCRYPTION_KEY_VERSION || "v1", + status: "pending", + }) + .returning({ id: billingWebhookEvents.id }); + if (!stored) + return res + .status(500) + .json({ error: "webhook_persistence_failed" }); + await claimBillingWebhookEvent(stored.id); + res.status(202).json({ accepted: true }); + // Processing is durable and asynchronous; consume the terminal + // rejection so a quarantined event cannot become an unhandled + // promise rejection in the API process. + // Read the durable envelope again in the worker. Subscription + // events are then refreshed from the provider before projection; + // provider outages become inbox retries rather than failed webhook + // deliveries that exist only in the provider's retry queue. + void processBillingWebhookInboxEvent(stored.id).catch( + () => undefined, + ); + } catch (error: any) { + if (error?.code === "23505") { + return res + .status(200) + .json({ accepted: true, duplicate: true }); + } + return res + .status(500) + .json({ error: "webhook_persistence_failed" }); + } + }, +); + +export default router; diff --git a/apps/api/src/contacts/queries.ts b/apps/api/src/contacts/queries.ts index 69f2ac7..619103f 100644 --- a/apps/api/src/contacts/queries.ts +++ b/apps/api/src/contacts/queries.ts @@ -7,6 +7,7 @@ import { emailDeliveries, sequenceEmails, sequences, + teams, } from "../db/schema"; // `contacts.contactId` auto-generates via `$defaultFn` (see `db/schema.ts`); // `generateUniqueId` is only still needed here for `unsubscribeToken`, an @@ -19,6 +20,7 @@ import { buildContactFilterCondition, type ContactFilterWithAggregator, } from "./segment"; +import { reserveSubscribedContactSlot } from "../billing/entitlements"; export type Contact = typeof contacts.$inferSelect; type ContactListFilter = @@ -37,20 +39,55 @@ export async function createContact({ tags?: string[]; customFields?: CustomFields; }): Promise { - const [contact] = await db - .insert(contacts) - .values({ - teamId, - email: email.toLowerCase().trim(), - name, - tags, - customFields, - unsubscribeToken: generateUniqueId(), - }) - .onConflictDoNothing({ target: [contacts.teamId, contacts.email] }) - .returning(); + const result = await db.transaction(async (tx) => { + const [team] = await tx + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) throw new Error("team_not_found"); + const [existing] = await tx + .select() + .from(contacts) + .where( + and( + eq(contacts.teamId, teamId), + eq(contacts.email, email.toLowerCase().trim()), + ), + ) + .limit(1) + .for("update"); + if (existing) return { contact: existing, created: false }; + await reserveSubscribedContactSlot(tx, team.organizationId, teamId); + const [created] = await tx + .insert(contacts) + .values({ + teamId, + email: email.toLowerCase().trim(), + name, + tags, + customFields, + unsubscribeToken: generateUniqueId(), + }) + .onConflictDoNothing({ target: [contacts.teamId, contacts.email] }) + .returning(); + if (created) return { contact: created, created: true }; + const [raced] = await tx + .select() + .from(contacts) + .where( + and( + eq(contacts.teamId, teamId), + eq(contacts.email, email.toLowerCase().trim()), + ), + ) + .limit(1); + if (!raced) throw new Error("contact_create_race_failed"); + return { contact: raced, created: false }; + }); - if (contact) { + if (result.created) { + const contact = result.contact; await syncContactCustomFieldValues({ teamId, contactId: contact.id, @@ -70,10 +107,7 @@ export async function createContact({ return contact; } - // Already existed — return the existing row (mirrors CourseLit's - // createSubscription which is a find-or-create). - const existing = await findContactByEmail(teamId, email); - return existing as Contact; + return result.contact; } export async function findContactByEmail( @@ -194,13 +228,35 @@ export async function updateContact( Pick >, ): Promise { - const [row] = await db - .update(contacts) - .set({ ...patch, updatedAt: new Date() }) - .where( - and(eq(contacts.teamId, teamId), eq(contacts.contactId, contactId)), - ) - .returning(); + const row = await db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(contacts) + .where( + and( + eq(contacts.teamId, teamId), + eq(contacts.contactId, contactId), + ), + ) + .limit(1) + .for("update"); + if (!current) return null; + if (patch.subscribed === true && !current.subscribed) { + const [team] = await tx + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) throw new Error("team_not_found"); + await reserveSubscribedContactSlot(tx, team.organizationId, teamId); + } + const [updated] = await tx + .update(contacts) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(contacts.id, current.id)) + .returning(); + return updated ?? null; + }); if (row && Object.prototype.hasOwnProperty.call(patch, "customFields")) { await syncContactCustomFieldValues({ teamId, diff --git a/apps/api/src/contacts/routes.ts b/apps/api/src/contacts/routes.ts index ff0997a..b5975cb 100644 --- a/apps/api/src/contacts/routes.ts +++ b/apps/api/src/contacts/routes.ts @@ -21,6 +21,7 @@ import { import { getSegment } from "./segments-queries"; import { serializeDates } from "../utils/serialize"; import { omitInternal } from "../utils/public"; +import { isPlanGateError } from "../billing/errors"; const router = Router(); router.use("/contacts", requireAuth, requireTeam); @@ -36,11 +37,21 @@ const s = initServer(); */ const impl = s.router(contract.contacts, { create: async ({ body, req }) => { - const contact = await createContact({ - teamId: (req as any).teamId, - ...body, - }); - return { status: 201, body: serializeDates(omitInternal(contact)) }; + try { + const contact = await createContact({ + teamId: (req as any).teamId, + ...body, + }); + return { status: 201, body: serializeDates(omitInternal(contact)) }; + } catch (error) { + if (isPlanGateError(error)) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; + } + throw error; + } }, list: async ({ query, req }) => { const teamId = (req as any).teamId; @@ -97,11 +108,22 @@ const impl = s.router(contract.contacts, { return { status: 200, body: serializeDates(omitInternal(contact)) }; }, update: async ({ params, body, req }) => { - const contact = await updateContact( - (req as any).teamId, - params.contactId, - body, - ); + let contact; + try { + contact = await updateContact( + (req as any).teamId, + params.contactId, + body, + ); + } catch (error) { + if (isPlanGateError(error)) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; + } + throw error; + } if (!contact) return { status: 404, body: { error: "Contact not found" } }; return { status: 200, body: serializeDates(omitInternal(contact)) }; diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 5b4f050..dcc7aa0 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -77,7 +77,7 @@ export const organizations = pgTable( ), statusCheck: check( "organizations_status_check", - sql`${table.status} IN ('active', 'suspended', 'closed')`, + sql`${table.status} IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')`, ), }), ); @@ -97,6 +97,705 @@ export const user = pgTable("user", { updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }); +/** + * Provider-neutral prices loaded from deployment configuration and verified + * against the provider catalog. Price entries are immutable; a price change + * creates a new provider product and entry so existing subscriptions remain + * grandfathered. + */ +export const billingPriceEntries = pgTable( + "billing_price_entries", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + catalogKey: text("catalog_key").notNull(), + plan: text("plan").notNull(), + billingInterval: text("billing_interval").notNull(), + currency: text("currency").notNull(), + amountMinor: integer("amount_minor").notNull(), + provider: text("provider").notNull(), + providerProductId: text("provider_product_id").notNull(), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + providerProductUnique: uniqueIndex( + "billing_price_entries_provider_product_uidx", + ).on(table.provider, table.providerProductId), + catalogKeyIdx: index("billing_price_entries_catalog_key_idx").on( + table.catalogKey, + ), + amountCheck: check( + "billing_price_entries_amount_check", + sql`${table.amountMinor} > 0`, + ), + currencyCheck: check( + "billing_price_entries_currency_check", + sql`${table.currency} ~ '^[A-Z]{3}$'`, + ), + planCheck: check( + "billing_price_entries_plan_check", + sql`${table.plan} IN ('pro', 'business')`, + ), + intervalCheck: check( + "billing_price_entries_interval_check", + sql`${table.billingInterval} IN ('month', 'year')`, + ), + }), +); + +export const billingCatalogRevisions = pgTable( + "billing_catalog_revisions", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + revision: integer("revision").notNull().unique(), + checkoutProvider: text("checkout_provider").notNull(), + status: text("status").notNull().default("pending_verification"), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + activatedAt: timestamp("activated_at", { withTimezone: true }), + retiredAt: timestamp("retired_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + statusCheck: check( + "billing_catalog_revisions_status_check", + sql`${table.status} IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')`, + ), + revisionCheck: check( + "billing_catalog_revisions_revision_check", + sql`${table.revision} > 0`, + ), + activeProviderUnique: uniqueIndex( + "billing_catalog_revisions_active_provider_uidx", + ) + .on(table.checkoutProvider) + .where(sql`${table.status} = 'active'`), + }), +); + +export const billingCatalogRevisionItems = pgTable( + "billing_catalog_revision_items", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + catalogRevisionId: uuid("catalog_revision_id") + .notNull() + .references(() => billingCatalogRevisions.id, { + onDelete: "cascade", + }), + catalogKey: text("catalog_key").notNull(), + billingPriceEntryId: uuid("billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { + onDelete: "restrict", + }), + }, + (table) => ({ + revisionKeyUnique: uniqueIndex( + "billing_catalog_revision_items_revision_key_uidx", + ).on(table.catalogRevisionId, table.catalogKey), + revisionPriceUnique: uniqueIndex( + "billing_catalog_revision_items_revision_price_uidx", + ).on(table.catalogRevisionId, table.billingPriceEntryId), + }), +); + +/** One provider customer per authenticated payer and provider. */ +export const billingProviderCustomers = pgTable( + "billing_provider_customers", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + provider: text("provider").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + providerCustomerId: text("provider_customer_id"), + idempotencyKey: text("idempotency_key").notNull(), + status: text("status").notNull().default("creating"), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + providerUserUnique: uniqueIndex( + "billing_provider_customers_provider_user_uidx", + ).on(table.provider, table.userId), + providerCustomerUnique: uniqueIndex( + "billing_provider_customers_provider_customer_uidx", + ) + .on(table.provider, table.providerCustomerId) + .where(sql`${table.providerCustomerId} IS NOT NULL`), + idempotencyUnique: uniqueIndex( + "billing_provider_customers_idempotency_uidx", + ).on(table.idempotencyKey), + statusCheck: check( + "billing_provider_customers_status_check", + sql`${table.status} IN ('creating', 'active', 'conflicted')`, + ), + }), +); + +/** A durable checkout/subscription correlation state machine. */ +export const billingCheckoutAttempts = pgTable( + "billing_checkout_attempts", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + attemptId: text("attempt_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("bca")), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + payerUserId: text("payer_user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + provider: text("provider").notNull(), + catalogRevision: integer("catalog_revision").notNull(), + catalogKey: text("catalog_key").notNull(), + requestedPlan: text("requested_plan").notNull(), + requestedInterval: text("requested_interval").notNull(), + pendingTeamName: text("pending_team_name"), + billingPriceEntryId: uuid("billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + quotedAmountMinor: integer("quoted_amount_minor").notNull(), + quotedCurrency: text("quoted_currency").notNull(), + billingCustomerId: uuid("billing_customer_id").references( + () => billingProviderCustomers.id, + { onDelete: "restrict" }, + ), + providerCheckoutSessionId: text("provider_checkout_session_id"), + checkoutUrlEncrypted: text("checkout_url_encrypted"), + idempotencyKey: text("idempotency_key").notNull(), + status: text("status").notNull().default("creating"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + lastError: text("last_error"), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + providerSessionUnique: uniqueIndex( + "billing_checkout_attempts_provider_session_uidx", + ) + .on(table.provider, table.providerCheckoutSessionId) + .where(sql`${table.providerCheckoutSessionId} IS NOT NULL`), + idempotencyUnique: uniqueIndex( + "billing_checkout_attempts_idempotency_uidx", + ).on(table.idempotencyKey), + organizationNonterminalUnique: uniqueIndex( + "billing_checkout_attempts_organization_nonterminal_uidx", + ) + .on(table.organizationId) + .where(sql`${table.status} IN ('creating', 'open')`), + statusCheck: check( + "billing_checkout_attempts_status_check", + sql`${table.status} IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')`, + ), + amountCheck: check( + "billing_checkout_attempts_amount_check", + sql`${table.quotedAmountMinor} > 0`, + ), + }), +); + +/** Durable, idempotent mutation record for changing an existing subscription. + * Entitlements are not projected from this row; the verified provider + * subscription snapshot remains authoritative. */ +export const billingPlanChangeAttempts = pgTable( + "billing_plan_change_attempts", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + changeId: text("change_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("bpc")), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + subscriptionId: uuid("subscription_id") + .notNull() + .references(() => organizationSubscriptions.id, { + onDelete: "restrict", + }), + actorUserId: text("actor_user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + provider: text("provider").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + currentCatalogRevision: integer("current_catalog_revision").notNull(), + currentBillingPriceEntryId: uuid("current_billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + currentPlan: text("current_plan").notNull(), + currentInterval: text("current_interval").notNull(), + targetCatalogRevision: integer("target_catalog_revision").notNull(), + targetBillingPriceEntryId: uuid("target_billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + targetPlan: text("target_plan").notNull(), + targetInterval: text("target_interval").notNull(), + effectiveAt: text("effective_at").notNull(), + prorationMode: text("proration_mode").notNull(), + providerPaymentId: text("provider_payment_id"), + paymentUrlEncrypted: text("payment_url_encrypted"), + status: text("status").notNull().default("creating"), + lastError: text("last_error"), + requestedAt: timestamp("requested_at", { withTimezone: true }) + .notNull() + .defaultNow(), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + idempotencyUnique: uniqueIndex( + "billing_plan_change_attempts_idempotency_uidx", + ).on(table.idempotencyKey), + organizationNonterminalUnique: uniqueIndex( + "billing_plan_change_attempts_organization_nonterminal_uidx", + ) + .on(table.organizationId) + .where(sql`${table.status} IN ('creating', 'pending')`), + statusCheck: check( + "billing_plan_change_attempts_status_check", + sql`${table.status} IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')`, + ), + effectiveAtCheck: check( + "billing_plan_change_attempts_effective_at_check", + sql`${table.effectiveAt} IN ('immediately', 'next_billing_date')`, + ), + prorationModeCheck: check( + "billing_plan_change_attempts_proration_mode_check", + sql`${table.prorationMode} IN ('prorated_immediately', 'do_not_bill')`, + ), + currentPlanCheck: check( + "billing_plan_change_attempts_current_plan_check", + sql`${table.currentPlan} IN ('pro', 'business')`, + ), + targetPlanCheck: check( + "billing_plan_change_attempts_target_plan_check", + sql`${table.targetPlan} IN ('pro', 'business')`, + ), + currentIntervalCheck: check( + "billing_plan_change_attempts_current_interval_check", + sql`${table.currentInterval} IN ('month', 'year')`, + ), + targetIntervalCheck: check( + "billing_plan_change_attempts_target_interval_check", + sql`${table.targetInterval} IN ('month', 'year')`, + ), + revisionCheck: check( + "billing_plan_change_attempts_revision_check", + sql`${table.currentCatalogRevision} > 0 AND ${table.targetCatalogRevision} > 0`, + ), + }), +); + +export const billingTrialClaims = pgTable( + "billing_trial_claims", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + verifiedEmailFingerprint: text("verified_email_fingerprint").notNull(), + fingerprintKeyVersion: text("fingerprint_key_version").notNull(), + trialKey: text("trial_key").notNull(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + checkoutAttemptId: uuid("checkout_attempt_id").references( + () => billingCheckoutAttempts.id, + { onDelete: "restrict" }, + ), + status: text("status").notNull().default("reserved"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + redeemedAt: timestamp("redeemed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + userTrialUnique: uniqueIndex("billing_trial_claims_user_trial_uidx") + .on(table.userId, table.trialKey) + .where(sql`${table.status} <> 'released'`), + emailTrialUnique: uniqueIndex("billing_trial_claims_email_trial_uidx") + .on(table.verifiedEmailFingerprint, table.trialKey) + .where(sql`${table.status} <> 'released'`), + statusCheck: check( + "billing_trial_claims_status_check", + sql`${table.status} IN ('reserved', 'redeemed', 'released')`, + ), + }), +); + +/** Historical subscription identity; organization_plan_states is only its projection. */ +export const organizationSubscriptions = pgTable( + "organization_subscriptions", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + billingCustomerId: uuid("billing_customer_id") + .notNull() + .references(() => billingProviderCustomers.id, { + onDelete: "restrict", + }), + billingManagerUserId: text("billing_manager_user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + provider: text("provider").notNull(), + providerSubscriptionId: text("provider_subscription_id").notNull(), + providerProductId: text("provider_product_id").notNull(), + billingPriceEntryId: uuid("billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + catalogKey: text("catalog_key").notNull(), + plan: text("plan").notNull(), + billingInterval: text("billing_interval").notNull(), + status: text("status").notNull().default("pending"), + currentPeriodStartsAt: timestamp("current_period_starts_at", { + withTimezone: true, + }), + currentPeriodEndsAt: timestamp("current_period_ends_at", { + withTimezone: true, + }), + paidThroughAt: timestamp("paid_through_at", { withTimezone: true }), + trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }), + pastDueAt: timestamp("past_due_at", { withTimezone: true }), + graceEndsAt: timestamp("grace_ends_at", { withTimezone: true }), + cancelAtPeriodEnd: boolean("cancel_at_period_end") + .notNull() + .default(false), + isEntitlementSource: boolean("is_entitlement_source") + .notNull() + .default(false), + lastProviderEventAt: timestamp("last_provider_event_at", { + withTimezone: true, + }), + lastReconciledAt: timestamp("last_reconciled_at", { + withTimezone: true, + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + providerSubscriptionUnique: uniqueIndex( + "organization_subscriptions_provider_subscription_uidx", + ).on(table.provider, table.providerSubscriptionId), + organizationSourceUnique: uniqueIndex( + "organization_subscriptions_organization_source_uidx", + ) + .on(table.organizationId) + .where(sql`${table.isEntitlementSource} = true`), + statusCheck: check( + "organization_subscriptions_status_check", + sql`${table.status} IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')`, + ), + planCheck: check( + "organization_subscriptions_plan_check", + sql`${table.plan} IN ('pro', 'business')`, + ), + intervalCheck: check( + "organization_subscriptions_interval_check", + sql`${table.billingInterval} IN ('month', 'year')`, + ), + }), +); + +export const organizationPlanStates = pgTable( + "organization_plan_states", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .unique() + .references(() => organizations.id, { onDelete: "restrict" }), + plan: text("plan").notNull().default("free"), + activeSubscriptionId: uuid("active_subscription_id").references( + () => organizationSubscriptions.id, + { onDelete: "restrict" }, + ), + teamsLimitOverride: integer("teams_limit_override"), + contactsLimitOverride: integer("contacts_limit_override"), + projectionVersion: integer("projection_version").notNull().default(0), + firstPaidActivatedAt: timestamp("first_paid_activated_at", { + withTimezone: true, + }), + rampStage: integer("ramp_stage").notNull().default(0), + rampCleanStageDays: integer("ramp_clean_stage_days") + .notNull() + .default(0), + rampEvaluatedAt: timestamp("ramp_evaluated_at", { + withTimezone: true, + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + planCheck: check( + "organization_plan_states_plan_check", + sql`${table.plan} IN ('free', 'pro', 'business')`, + ), + teamsOverrideCheck: check( + "organization_plan_states_teams_override_check", + sql`${table.teamsLimitOverride} IS NULL OR ${table.teamsLimitOverride} > 0`, + ), + contactsOverrideCheck: check( + "organization_plan_states_contacts_override_check", + sql`${table.contactsLimitOverride} IS NULL OR ${table.contactsLimitOverride} > 0`, + ), + rampStageCheck: check( + "organization_plan_states_ramp_stage_check", + sql`${table.rampStage} BETWEEN 0 AND 3`, + ), + rampCleanDaysCheck: check( + "organization_plan_states_ramp_clean_days_check", + sql`${table.rampCleanStageDays} >= 0`, + ), + }), +); + +export const billingWebhookEvents = pgTable( + "billing_webhook_events", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + provider: text("provider").notNull(), + providerEventId: text("provider_event_id").notNull(), + eventType: text("event_type").notNull(), + occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(), + payloadEncrypted: text("payload_encrypted"), + payloadKeyVersion: text("payload_key_version"), + status: text("status").notNull().default("pending"), + processingAttempts: integer("processing_attempts").notNull().default(0), + lastError: text("last_error"), + availableAt: timestamp("available_at", { withTimezone: true }) + .notNull() + .defaultNow(), + lockedAt: timestamp("locked_at", { withTimezone: true }), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + workerId: text("worker_id"), + receivedAt: timestamp("received_at", { withTimezone: true }) + .notNull() + .defaultNow(), + processedAt: timestamp("processed_at", { withTimezone: true }), + }, + (table) => ({ + providerEventUnique: uniqueIndex( + "billing_webhook_events_provider_event_uidx", + ).on(table.provider, table.providerEventId), + queueIdx: index("billing_webhook_events_queue_idx").on( + table.status, + table.availableAt, + ), + statusCheck: check( + "billing_webhook_events_status_check", + sql`${table.status} IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')`, + ), + }), +); + +export const planSendUsageBuckets = pgTable( + "plan_send_usage_buckets", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + bucketMonth: timestamp("bucket_month", { + withTimezone: true, + }).notNull(), + committed: integer("committed").notNull().default(0), + reserved: integer("reserved").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationMonthUnique: uniqueIndex( + "plan_send_usage_buckets_organization_month_uidx", + ).on(table.organizationId, table.bucketMonth), + countCheck: check( + "plan_send_usage_buckets_count_check", + sql`${table.committed} >= 0 AND ${table.reserved} >= 0`, + ), + }), +); + +export const planSendReservations = pgTable( + "plan_send_reservations", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + outboundMessageId: uuid("outbound_message_id").notNull(), + bucketId: uuid("bucket_id") + .notNull() + .references(() => planSendUsageBuckets.id, { + onDelete: "restrict", + }), + amount: integer("amount").notNull().default(1), + state: text("state").notNull().default("reserved"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + committedAt: timestamp("committed_at", { withTimezone: true }), + releasedAt: timestamp("released_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + outboundUnique: uniqueIndex("plan_send_reservations_outbound_uidx").on( + table.outboundMessageId, + ), + expiryIdx: index("plan_send_reservations_expiry_idx").on( + table.state, + table.expiresAt, + ), + amountCheck: check( + "plan_send_reservations_amount_check", + sql`${table.amount} > 0`, + ), + stateCheck: check( + "plan_send_reservations_state_check", + sql`${table.state} IN ('reserved', 'committed', 'released')`, + ), + }), +); + +export const teamSendingControls = pgTable( + "team_sending_controls", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .unique() + .references(() => teams.id, { onDelete: "restrict" }), + status: text("status").notNull().default("normal"), + reasonCode: text("reason_code"), + source: text("source").notNull().default("automatic"), + enteredAt: timestamp("entered_at", { withTimezone: true }), + evaluatedAt: timestamp("evaluated_at", { withTimezone: true }), + minimumHoldUntil: timestamp("minimum_hold_until", { + withTimezone: true, + }), + operatorUserId: text("operator_user_id").references(() => user.id, { + onDelete: "restrict", + }), + operatorReason: text("operator_reason"), + overriddenAt: timestamp("overridden_at", { withTimezone: true }), + cleanEvaluationDays: integer("clean_evaluation_days") + .notNull() + .default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + statusCheck: check( + "team_sending_controls_status_check", + sql`${table.status} IN ('normal', 'warned', 'marketing_paused', 'all_paused')`, + ), + sourceCheck: check( + "team_sending_controls_source_check", + sql`${table.source} IN ('automatic', 'operator')`, + ), + cleanDaysCheck: check( + "team_sending_controls_clean_days_check", + sql`${table.cleanEvaluationDays} >= 0`, + ), + }), +); + +export const sendingDomains = pgTable( + "sending_domains", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + domainId: text("domain_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("domain")), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + domain: text("domain").notNull(), + challengeTokenHash: text("challenge_token_hash").notNull(), + status: text("status").notNull().default("pending"), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }), + nextCheckAt: timestamp("next_check_at", { withTimezone: true }), + failedCheckCount: integer("failed_check_count").notNull().default(0), + firstFailedAt: timestamp("first_failed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + domainIdCheck: publicIdCheck( + "sending_domains_domain_id_check", + table.domainId, + "domain", + ), + organizationDomainUnique: uniqueIndex( + "sending_domains_organization_domain_uidx", + ).on(table.organizationId, table.domain), + statusCheck: check( + "sending_domains_status_check", + sql`${table.status} IN ('pending', 'verified', 'revoked', 'failed')`, + ), + failedCheckCountCheck: check( + "sending_domains_failed_check_count_check", + sql`${table.failedCheckCount} >= 0`, + ), + }), +); + /** Explicit organization authorization; authentication alone grants nothing. */ export const organizationMembers = pgTable( "organization_members", diff --git a/apps/api/src/delivery-feedback/outbound-queries.ts b/apps/api/src/delivery-feedback/outbound-queries.ts index f5355e8..dbbb7c5 100644 --- a/apps/api/src/delivery-feedback/outbound-queries.ts +++ b/apps/api/src/delivery-feedback/outbound-queries.ts @@ -3,6 +3,8 @@ import { db } from "../db/client"; import { outboundMessages } from "../db/schema"; import type { OutboundSourceType } from "../config/constants"; +type Transaction = Parameters[0]>[0]; + export type OutboundMessage = typeof outboundMessages.$inferSelect; /** @@ -25,8 +27,10 @@ export async function createOutboundMessage(input: { normalizedRecipient: string; provider: string | null; rfcMessageId: string; + tx?: Transaction; }): Promise { - const [row] = await db + const database = input.tx ?? db; + const [row] = await database .insert(outboundMessages) .values({ teamId: input.teamId, @@ -49,7 +53,7 @@ export async function createOutboundMessage(input: { if (!input.submissionKey) { throw new Error("outbound_message_not_created"); } - const [existing] = await db + const [existing] = await database .select() .from(outboundMessages) .where(eq(outboundMessages.submissionKey, input.submissionKey)) diff --git a/apps/api/src/delivery-feedback/outbound-send.ts b/apps/api/src/delivery-feedback/outbound-send.ts index 1b5c33e..c7b35b4 100644 --- a/apps/api/src/delivery-feedback/outbound-send.ts +++ b/apps/api/src/delivery-feedback/outbound-send.ts @@ -5,6 +5,11 @@ import { type OutboundMessage, } from "./outbound-queries"; import type { OutboundSourceType } from "../config/constants"; +import { db } from "../db/client"; +import { teams } from "../db/schema"; +import { reserveSend } from "../billing/entitlements"; +import { reserveOrganizationQuota } from "../delivery/quota"; +import { eq } from "drizzle-orm"; /** * Creates the outbound-ledger row for an already-authorized pinned source, @@ -26,6 +31,7 @@ export async function createPinnedOutboundMessage({ transactionalEmailId, recipientEmail, normalizedRecipient, + organizationQuotaGrantId, }: { teamId: string; deliverySourceType: "organization" | "team"; @@ -38,24 +44,50 @@ export async function createPinnedOutboundMessage({ transactionalEmailId?: string | null; recipientEmail: string; normalizedRecipient: string; + organizationQuotaGrantId?: string | null; }): Promise<{ outbound: OutboundMessage; rfcMessageId: string }> { const rfcMessageId = generateRfcMessageId(); const connection = await getActiveFeedbackConnectionForEspConfig(espConfigId); - const outbound = await createOutboundMessage({ - teamId, - deliverySourceType, - espConfigId, - espGrantId, - feedbackConnectionId: connection?.id ?? null, - sourceType, - submissionKey, - campaignDeliveryId, - transactionalEmailId, - recipientEmail, - normalizedRecipient, - provider, - rfcMessageId, + const [team] = await db + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + if (!team) throw new Error("team_not_found"); + const outbound = await db.transaction(async (tx) => { + const created = await createOutboundMessage({ + teamId, + deliverySourceType, + espConfigId, + espGrantId, + feedbackConnectionId: connection?.id ?? null, + sourceType, + submissionKey, + campaignDeliveryId, + transactionalEmailId, + recipientEmail, + normalizedRecipient, + provider, + rfcMessageId, + tx, + }); + // A prior attempt may already have reached the provider. Do not run a + // fresh plan gate for that terminal ledger row; callers can complete + // their local workflow action idempotently without resubmitting mail. + if (created.deliveryStatus === "accepted") return created; + await reserveSend(tx, { + organizationId: team.organizationId, + outboundMessageId: created.id, + purpose: sourceType === "campaign" ? "marketing" : "transactional", + }); + if (organizationQuotaGrantId) { + await reserveOrganizationQuota(tx, { + outboundMessageId: created.id, + grantId: organizationQuotaGrantId, + }); + } + return created; }); return { outbound, rfcMessageId: outbound.rfcMessageId || rfcMessageId }; } diff --git a/apps/api/src/delivery-feedback/process-receipt.ts b/apps/api/src/delivery-feedback/process-receipt.ts index ee387ab..7440af8 100644 --- a/apps/api/src/delivery-feedback/process-receipt.ts +++ b/apps/api/src/delivery-feedback/process-receipt.ts @@ -26,6 +26,7 @@ import { applyEventToProjection } from "./projection"; import { addOrStrengthenSuppression } from "./suppression-queries"; import { computeFinalSoftBounceStreak } from "./soft-bounce-streak"; import { recordFeedbackConnectionVerified } from "./feedback-connection-queries"; +import { evaluateTeamReputation } from "../billing/reputation"; /** * Claims and normalizes one durable receipt into canonical events, applying @@ -160,6 +161,16 @@ async function processOneEvent( event.eventType, event.occurredAt, ); + if ( + event.eventType === "hard_bounce" || + event.eventType === "soft_bounce" || + event.eventType === "rejected" || + event.eventType === "complaint" + ) { + // A reputation calculation must never make an authenticated + // provider receipt retry; the hourly sweep is the durable backup. + await evaluateTeamReputation(teamId).catch(() => undefined); + } } await applySuppressionSideEffect({ diff --git a/apps/api/src/delivery/queries.ts b/apps/api/src/delivery/queries.ts index 1de80a9..c874960 100644 --- a/apps/api/src/delivery/queries.ts +++ b/apps/api/src/delivery/queries.ts @@ -14,6 +14,12 @@ import { } from "../db/schema"; import { releaseReservedQuotaForGrantInTransaction } from "./quota"; import { recordOrganizationAuditEvent } from "../organization/audit"; +import { + assertCapability, + assertSendAllowedForTeam, + getOrganizationEntitlements, +} from "../billing/entitlements"; +import { assertSendingEligibility } from "../billing/domains"; export type OrganizationDeliveryPolicy = typeof organizationDeliveryPolicies.$inferSelect; @@ -40,7 +46,9 @@ export type ResolvedDeliverySource = { export async function resolveDeliverySource( teamId: string, requested?: DeliverySourceSelection, + purpose: "marketing" | "transactional" = "transactional", ): Promise { + await assertSendAllowedForTeam(teamId, purpose); const [context] = await db .select({ team: teams, @@ -98,6 +106,12 @@ export async function resolveDeliverySource( ? { type: "organization" } : { type: "team" }; } + if (selection?.type === "organization") { + assertCapability( + await getOrganizationEntitlements(context.team.organizationId), + "shared_organization_mailbox", + ); + } if (selection.type === "organization") { const [row] = await db @@ -120,6 +134,12 @@ export async function resolveDeliverySource( if (!row?.esp.fromEmail) { throw new Error("organization_delivery_disabled"); } + await assertSendingEligibility( + context.team.organizationId, + row.esp.fromEmail, + context.team.id, + row.esp.id, + ); return { type: "organization", espConfigId: row.esp.id, @@ -171,6 +191,12 @@ export async function resolveDeliverySource( ) .limit(1); if (!esp?.fromEmail) throw new Error("esp_not_configured"); + await assertSendingEligibility( + context.team.organizationId, + esp.fromEmail, + context.team.id, + esp.id, + ); return { type: "team", espConfigId: esp.id, @@ -191,7 +217,25 @@ export async function resolvePinnedDeliverySource(input: { type: "organization" | "team"; espConfigId: string; espGrantId: string | null; + purpose?: "marketing" | "transactional"; }): Promise { + await assertSendAllowedForTeam( + input.teamId, + input.purpose ?? "transactional", + ); + if (input.type === "organization") { + const [team] = await db + .select({ organizationId: teams.organizationId }) + .from(teams) + .where(eq(teams.id, input.teamId)) + .limit(1); + if (team) { + assertCapability( + await getOrganizationEntitlements(team.organizationId), + "shared_organization_mailbox", + ); + } + } if (input.type === "team") { if (input.espGrantId) throw new Error("invalid_delivery_pin"); const [row] = await db @@ -216,6 +260,12 @@ export async function resolvePinnedDeliverySource(input: { )); if (!row?.esp.fromEmail || !dispatchableEsp) throw new Error("delivery_source_unavailable"); + await assertSendingEligibility( + row.team.organizationId, + row.esp.fromEmail, + row.team.id, + row.esp.id, + ); return { type: "team", espConfigId: row.esp.id, @@ -277,6 +327,12 @@ export async function resolvePinnedDeliverySource(input: { ) { throw new Error("delivery_source_unavailable"); } + await assertSendingEligibility( + row.team.organizationId, + row.esp.fromEmail, + row.team.id, + row.esp.id, + ); return { type: "organization", espConfigId: row.esp.id, diff --git a/apps/api/src/delivery/quota.ts b/apps/api/src/delivery/quota.ts index 2feea25..ad15e95 100644 --- a/apps/api/src/delivery/quota.ts +++ b/apps/api/src/delivery/quota.ts @@ -93,6 +93,24 @@ export async function reserveOrganizationQuota( throw new Error("organization_delivery_disabled"); } + const [existingReservation] = await tx + .select() + .from(organizationEspQuotaReservations) + .where( + eq( + organizationEspQuotaReservations.outboundMessageId, + input.outboundMessageId, + ), + ) + .limit(1) + .for("update"); + if ( + existingReservation?.state === "reserved" || + existingReservation?.state === "committed" + ) { + return existingReservation; + } + const periods = utcPeriods(); const specs = [ { @@ -166,16 +184,36 @@ export async function reserveOrganizationQuota( }) .where(eq(organizationEspUsageBuckets.id, bucket.id)); } - const [reservation] = await tx - .insert(organizationEspQuotaReservations) - .values({ - outboundMessageId: input.outboundMessageId, - grantId: context.grant.id, - organizationId: context.grant.organizationId, - dayPeriodStart: periods.day, - monthPeriodStart: periods.month, - }) - .returning(); + const [reservation] = existingReservation + ? await tx + .update(organizationEspQuotaReservations) + .set({ + grantId: context.grant.id, + organizationId: context.grant.organizationId, + dayPeriodStart: periods.day, + monthPeriodStart: periods.month, + state: "reserved", + releaseReason: null, + committedAt: null, + releasedAt: null, + }) + .where( + eq( + organizationEspQuotaReservations.id, + existingReservation.id, + ), + ) + .returning() + : await tx + .insert(organizationEspQuotaReservations) + .values({ + outboundMessageId: input.outboundMessageId, + grantId: context.grant.id, + organizationId: context.grant.organizationId, + dayPeriodStart: periods.day, + monthPeriodStart: periods.month, + }) + .returning(); return reservation; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 64c49ad..9ca117e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -49,6 +49,15 @@ import { startFeedbackReceiptPoller } from "./delivery-feedback/poller"; import { startRetentionLoop } from "./delivery-feedback/retention-loop"; import { startMailDispatchOutbox } from "./mail/dispatch-outbox"; import { startDeliveryLifecycleJobs } from "./delivery/lifecycle-jobs"; +import { + assertBillingProviderConfig, + readBillingConfig, +} from "./billing/catalog"; +import { recordRequestedCatalogRevision } from "./billing/catalog-store"; +import billingRoutes from "./billing/routes"; +import billingWebhookRoutes from "./billing/webhooks/routes"; +import { assertBillingEncryptionKeyConfigured } from "./billing/crypto"; +import { startBillingReconciliation } from "./billing/reconciliation"; const app = express(); startMailDispatchOutbox(); @@ -83,6 +92,7 @@ app.use(mcpRoutes); // request bytes for provider signature verification and has no // session/API-key concept at all — see delivery-feedback/webhook-route.ts. app.use(espWebhookRoutes); +app.use(billingWebhookRoutes); app.use(express.json()); app.use(express.urlencoded({ extended: false })); @@ -115,6 +125,7 @@ app.use( // their traffic with `router.use(requireAuth)`, anything mounted after them // would otherwise be incorrectly blocked by that blanket check. app.use(trackingRoutes); +app.use(billingRoutes); app.use(provisioningRoutes); app.use(organizationRoutes); @@ -175,12 +186,19 @@ const port = process.env.PORT || 80; checkConfig() .then(checkDatabaseConnection) + .then(async () => { + const billingConfig = readBillingConfig(); + if (billingConfig.deploymentMode === "cloud") { + await recordRequestedCatalogRevision(billingConfig); + } + }) .then(createSuperAdminIfMissing) .then(() => { app.listen(port, () => { logger.info(`SendLit API running at ${port}`); }); startAutomation(); + startBillingReconciliation(); startFeedbackReceiptPoller(); startRetentionLoop(); }) @@ -210,4 +228,12 @@ async function checkConfig() { } assertEspEncryptionKeyConfigured(); assertSuppressionHashKeyConfigured(); + // Billing mode is explicit and fail-closed. Local/self-hosted development + // must opt into OSS; cloud deployments must provide a complete, validated + // catalog instead of silently inheriting an environment default. + const billingConfig = readBillingConfig(); + if (billingConfig.deploymentMode === "cloud") { + assertBillingProviderConfig(billingConfig); + assertBillingEncryptionKeyConfigured(); + } } diff --git a/apps/api/src/mail/render.ts b/apps/api/src/mail/render.ts index e2cef9f..460ab96 100644 --- a/apps/api/src/mail/render.ts +++ b/apps/api/src/mail/render.ts @@ -268,11 +268,14 @@ export async function renderEmailContent({ content, variables, requireVariables = false, + brandingText, }: { content: EmailType; variables: Record; /** Transactional sends reject unguarded values that would render blank. */ requireVariables?: boolean; + /** Server-owned Free-plan branding; callers cannot supply it in template data. */ + brandingText?: string; }): Promise { const hasFooter = content.content.some( (block) => block.blockType === "footer", @@ -296,7 +299,7 @@ export async function renderEmailContent({ throw error; } renderContext = { - footer: { mailingAddress, unsubscribeUrl }, + footer: { mailingAddress, unsubscribeUrl, brandingText }, }; } diff --git a/apps/api/src/mail/worker.ts b/apps/api/src/mail/worker.ts index a98f0af..2532df9 100644 --- a/apps/api/src/mail/worker.ts +++ b/apps/api/src/mail/worker.ts @@ -28,10 +28,17 @@ import { markOutboundBounced, } from "../delivery-feedback/outbound-queries"; import { resolvePinnedDeliverySource } from "../delivery/queries"; +import { evaluateTeamReputation } from "../billing/reputation"; import { commitQuotaForOutbound, releaseQuotaForOutbound, } from "../delivery/quota"; +import { + commitSendReservation, + releaseSendReservation, + reserveSend, +} from "../billing/entitlements"; +import { db } from "../db/client"; async function processCampaignJob(job: Job) { const { to, from, subject, body, headers, teamId } = job.data; @@ -101,6 +108,15 @@ async function processTransactionalJob(job: Job) { }); outbound = await getOutboundMessageByTransactionalEmailId(row.id); + if (outbound) { + await db.transaction(async (tx) => { + await reserveSend(tx, { + organizationId: team.organizationId, + outboundMessageId: outbound!.id, + purpose: "transactional", + }); + }); + } // Recheck immediately before transport — closes the race between // enqueue and a bounce/complaint that suppressed this recipient in the @@ -110,6 +126,7 @@ async function processTransactionalJob(job: Job) { await markTransactionalEmailSuppressed(row.id); if (outbound) { await releaseQuotaForOutbound(outbound.id, "suppressed"); + await releaseSendReservation(outbound.id); } return; } @@ -160,6 +177,7 @@ async function processTransactionalJob(job: Job) { providerMessageId: result.providerResponse, }); await commitQuotaForOutbound(outbound.id); + await commitSendReservation(outbound.id); } } catch (err: any) { const responseCode = err?.responseCode; @@ -180,7 +198,9 @@ async function processTransactionalJob(job: Job) { await markTransactionalEmailBounced(row.id, err.message); if (outbound) { await markOutboundBounced(outbound.id); + await evaluateTeamReputation(row.teamId).catch(() => undefined); await releaseQuotaForOutbound(outbound.id, "provider_rejected"); + await releaseSendReservation(outbound.id); } // Mirrors this synchronous SMTP signal into the suppression // system directly — there is no webhook receipt/event backing @@ -210,6 +230,7 @@ async function processTransactionalJob(job: Job) { await markTransactionalEmailFailed(row.id, err.message); if (outbound) { await releaseQuotaForOutbound(outbound.id, "terminal_failure"); + await releaseSendReservation(outbound.id); } } else { await releaseTransactionalEmailClaim(row.id); diff --git a/apps/api/src/mcp/policy.ts b/apps/api/src/mcp/policy.ts index 3fda168..96567c1 100644 --- a/apps/api/src/mcp/policy.ts +++ b/apps/api/src/mcp/policy.ts @@ -95,6 +95,7 @@ const toolPolicies = { activate_esp: MCP_SCOPES.espWrite, list_teams: MCP_SCOPES.teamsRead, + get_plan_usage: MCP_SCOPES.teamsRead, create_team: MCP_SCOPES.teamsWrite, rename_team: MCP_SCOPES.teamsWrite, delete_team: MCP_SCOPES.teamsWrite, diff --git a/apps/api/src/mcp/server.test.ts b/apps/api/src/mcp/server.test.ts index e73c459..d0ec547 100644 --- a/apps/api/src/mcp/server.test.ts +++ b/apps/api/src/mcp/server.test.ts @@ -58,11 +58,11 @@ describe("MCP server", () => { expect(client.getProtocolEra()).toBe("modern"); const result = await client.listTools(); - expect(result.tools).toHaveLength(68); + expect(result.tools).toHaveLength(69); expect(result.tools.map((tool) => tool.name).sort()).toEqual( Object.keys(listMcpToolPolicies()).sort(), ); - expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(68); + expect(new Set(result.tools.map((tool) => tool.name)).size).toBe(69); expect(result.ttlMs).toBe(300_000); expect(result.cacheScope).toBe("private"); for (const tool of result.tools) { diff --git a/apps/api/src/mcp/tools/contacts.ts b/apps/api/src/mcp/tools/contacts.ts index 81c6f56..7b7af97 100644 --- a/apps/api/src/mcp/tools/contacts.ts +++ b/apps/api/src/mcp/tools/contacts.ts @@ -14,7 +14,14 @@ import { } from "../../contacts/queries"; import { getSegment } from "../../contacts/segments-queries"; import { contactFilterSchema } from "@sendlit/api-contract"; -import { AUTH_ERROR, INTERNAL_ERROR, NOT_FOUND, jsonResult } from "./responses"; +import { + AUTH_ERROR, + INTERNAL_ERROR, + NOT_FOUND, + jsonResult, + planGateResult, + isPlanGateError, +} from "./responses"; import { contactListSchema, contactSchema, @@ -88,7 +95,8 @@ export function registerContactTools(server: McpToolRegistrar): void { items: items.map((item) => omitInternal(item)), total, }); - } catch { + } catch (error) { + if (isPlanGateError(error)) return planGateResult(error); return INTERNAL_ERROR; } }, @@ -182,6 +190,7 @@ export function registerContactTools(server: McpToolRegistrar): void { const contact = await createContact({ teamId, ...args }); return jsonResult(omitInternal(contact)); } catch (err: any) { + if (isPlanGateError(err)) return planGateResult(err); return { content: [{ type: "text" as const, text: err.message }], isError: true, @@ -219,7 +228,8 @@ export function registerContactTools(server: McpToolRegistrar): void { const contact = await updateContact(teamId, contactId, patch); if (!contact) return NOT_FOUND; return jsonResult(omitInternal(contact)); - } catch { + } catch (error) { + if (isPlanGateError(error)) return planGateResult(error); return INTERNAL_ERROR; } }, diff --git a/apps/api/src/mcp/tools/responses.ts b/apps/api/src/mcp/tools/responses.ts index 9dfca07..9ed2c1c 100644 --- a/apps/api/src/mcp/tools/responses.ts +++ b/apps/api/src/mcp/tools/responses.ts @@ -39,3 +39,28 @@ export function jsonResult(data: unknown) { structuredContent: serialized, }; } + +export function planGateResult(error: { + code: string; + details?: Record; + status?: number; +}) { + const details = { error: error.code, ...(error.details ?? {}) }; + return { + content: [{ type: "text" as const, text: JSON.stringify(details) }], + structuredContent: details, + isError: true, + }; +} + +export function isPlanGateError(error: unknown): error is { + code: string; + details?: Record; + status?: number; +} { + return Boolean( + error && + typeof error === "object" && + (error as any).name === "PlanGateError", + ); +} diff --git a/apps/api/src/mcp/tools/sequences.ts b/apps/api/src/mcp/tools/sequences.ts index f16c1f3..a64473e 100644 --- a/apps/api/src/mcp/tools/sequences.ts +++ b/apps/api/src/mcp/tools/sequences.ts @@ -25,6 +25,8 @@ import { NOT_FOUND, errorResult, jsonResult, + planGateResult, + isPlanGateError, } from "./responses"; import { sequenceListSchema, @@ -373,6 +375,7 @@ export function registerSequenceTools(server: McpToolRegistrar): void { }); return jsonResult(toPublicSequence(sequence)); } catch (err: any) { + if (isPlanGateError(err)) return planGateResult(err); return errorResult(err.message); } }, diff --git a/apps/api/src/mcp/tools/teams.ts b/apps/api/src/mcp/tools/teams.ts index 8938204..87ed83d 100644 --- a/apps/api/src/mcp/tools/teams.ts +++ b/apps/api/src/mcp/tools/teams.ts @@ -13,7 +13,14 @@ import { deleteApiKey, getApiKeysByTeamId, } from "../../apikey/queries"; -import { AUTH_ERROR, INTERNAL_ERROR, NOT_FOUND, jsonResult } from "./responses"; +import { + AUTH_ERROR, + INTERNAL_ERROR, + NOT_FOUND, + jsonResult, + planGateResult, + isPlanGateError, +} from "./responses"; import { apiKeySchema, createdApiKeySchema, @@ -48,7 +55,62 @@ export function registerTeamTools(server: McpToolRegistrar): void { organizationName: t.organizationName, })), }); - } catch { + } catch (error) { + if (isPlanGateError(error)) return planGateResult(error); + return INTERNAL_ERROR; + } + }, + ); + + server.registerTool( + "get_plan_usage", + { + description: + "Returns plan usage and limits for the current team's organization.", + outputSchema: z.object({ + plan: z.enum(["oss", "free", "pro", "business"]), + paymentStatus: z.string(), + teams: z.number().int().nonnegative(), + subscribedContacts: z.number().int().nonnegative(), + monthlySends: z.number().int().nonnegative(), + monthlySendsReserved: z.number().int().nonnegative(), + bucketStartsAt: z.string(), + bucketEndsAt: z.string(), + teamsLimit: z.number().int().positive().nullable(), + subscribedContactsLimit: z.number().int().positive().nullable(), + monthlySendsLimit: z.number().int().positive().nullable(), + }), + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (_args: any, extra: any) => { + const teamId = getTeamId(extra); + if (!teamId) return AUTH_ERROR; + try { + const team = await getTeamByTeamId(teamId); + if (!team) return NOT_FOUND; + const { usageForOrganization } = + await import("../../billing/usage.js"); + const { getOrganizationEntitlements } = + await import("../../billing/entitlements.js"); + const [usage, entitlements] = await Promise.all([ + usageForOrganization(team.organizationId), + getOrganizationEntitlements(team.organizationId), + ]); + return jsonResult({ + ...usage, + plan: entitlements.plan, + paymentStatus: entitlements.paymentStatus, + teamsLimit: entitlements.teamsLimit, + subscribedContactsLimit: + entitlements.subscribedContactsLimit, + monthlySendsLimit: entitlements.monthlySendsLimit, + }); + } catch (error) { + if (isPlanGateError(error)) return planGateResult(error); return INTERNAL_ERROR; } }, @@ -58,7 +120,7 @@ export function registerTeamTools(server: McpToolRegistrar): void { "create_team", { description: - "Creates a new team in the authenticated user's default organization.", + "Creates a new team in the authenticated user's default organization. Subject to the parent organization's plan team limit.", inputSchema: { name: z.string().min(1).describe("Team name"), }, @@ -91,7 +153,8 @@ export function registerTeamTools(server: McpToolRegistrar): void { teamId: team.teamId, name: team.name, }); - } catch { + } catch (error) { + if (isPlanGateError(error)) return planGateResult(error); return INTERNAL_ERROR; } }, diff --git a/apps/api/src/mcp/tools/transactional.ts b/apps/api/src/mcp/tools/transactional.ts index d6cca47..9117fc9 100644 --- a/apps/api/src/mcp/tools/transactional.ts +++ b/apps/api/src/mcp/tools/transactional.ts @@ -18,7 +18,14 @@ import { MISSING_TEMPLATE_VARIABLES, MissingTemplateVariablesError, } from "../../mail/render"; -import { AUTH_ERROR, NOT_FOUND, errorResult, jsonResult } from "./responses"; +import { + AUTH_ERROR, + NOT_FOUND, + errorResult, + jsonResult, + planGateResult, + isPlanGateError, +} from "./responses"; import { getTeamId } from "./auth"; const transactionalEmailListSchema = z.object({ @@ -115,6 +122,7 @@ export function registerTransactionalTools(server: McpToolRegistrar): void { }); return jsonResult({ txeId: row.txeId, status: row.status }); } catch (err: any) { + if (isPlanGateError(err)) return planGateResult(err); if ( err instanceof MissingTemplateVariablesError || err?.message === MISSING_TEMPLATE_VARIABLES diff --git a/apps/api/src/observability/posthog.ts b/apps/api/src/observability/posthog.ts index 6aee521..74cd1c9 100644 --- a/apps/api/src/observability/posthog.ts +++ b/apps/api/src/observability/posthog.ts @@ -58,6 +58,10 @@ const CONTEXT_ALLOWLIST = new Set([ "response_code", "route", "worker_name", + "alert_code", + "billing_alert", + "age_ms", + "count", ]); const perSourceCap = getPerSourceCap(); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 8fd33d1..d351fb0 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -61,6 +61,11 @@ export const openApiDocument = generateOpenApi( description: "Normalized bounce/complaint delivery events and the per-workspace suppression (do-not-send) list. See docs/bounces-and-complaints.md.", }, + { + name: "Billing", + description: + "Organization-scoped plan, configured catalog, entitlement, and usage information.", + }, ], components: { securitySchemes: { diff --git a/apps/api/src/organization/default-team-name.ts b/apps/api/src/organization/default-team-name.ts new file mode 100644 index 0000000..91ef311 --- /dev/null +++ b/apps/api/src/organization/default-team-name.ts @@ -0,0 +1,11 @@ +/** + * Give an automatically-created team a stable, organization-specific name. + * Explicit team names still take precedence; this is only for bootstrap and + * other flows that need a safe default. + */ +export function defaultTeamName(organizationName: string): string { + const name = organizationName.trim(); + if (!name) return "Default Team"; + if (/\bteam$/i.test(name)) return name; + return `${name} Team`; +} diff --git a/apps/api/src/organization/enter-team.routes.test.ts b/apps/api/src/organization/enter-team.routes.test.ts index 03c0301..11c146e 100644 --- a/apps/api/src/organization/enter-team.routes.test.ts +++ b/apps/api/src/organization/enter-team.routes.test.ts @@ -289,3 +289,27 @@ describe("POST /organizations/:organizationId/teams/:teamId/enter", () => { ).toHaveLength(0); }); }); + +describe("POST /organizations", () => { + it("rejects case-only duplicate organization names for the owner", async () => { + const owner = await insertUser("Owner"); + authState.userId = owner.id; + + const first = await requestApp(app(), "/organizations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Acme" }), + }); + expect(first.status).toBe(201); + + const duplicate = await requestApp(app(), "/organizations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: " acME " }), + }); + expect(duplicate.status).toBe(409); + expect(duplicate.json()).toEqual({ + error: "organization_name_already_exists", + }); + }); +}); diff --git a/apps/api/src/organization/queries.test.ts b/apps/api/src/organization/queries.test.ts index 05c034b..39c9337 100644 --- a/apps/api/src/organization/queries.test.ts +++ b/apps/api/src/organization/queries.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../db/client", async () => { const { makeTestDb } = await import("../test/db.js"); @@ -8,16 +8,24 @@ vi.mock("../db/client", async () => { import { db } from "../db/client"; import { eq } from "drizzle-orm"; import { + billingCheckoutAttempts, + billingPriceEntries, + billingProviderCustomers, espConfigTeamGrants, espConfigs, organizationEspQuotaReservations, organizationEspUsageBuckets, + organizationPlanStates, + organizationSubscriptions, + organizations, outboundMessages, sequences, + teams, user, } from "../db/schema"; import { addOrganizationMemberByEmail, + closeOrganization, getOrganizationMembership, listOrganizationsForUser, createOrganization, @@ -42,6 +50,63 @@ beforeEach(async () => { }); describe("organizations", () => { + it("names an automatically-created initial team from its organization", async () => { + const [owner] = await tdb + .insert(user) + .values({ + id: crypto.randomUUID(), + name: "Owner", + email: `owner-${crypto.randomUUID()}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + const organization = await createOrganization(owner.id, "Acme", { + createInitialTeam: true, + }); + const [team] = await tdb + .select({ name: teams.name }) + .from(teams) + .where(eq(teams.organizationId, organization.id)) + .limit(1); + + expect(team?.name).toBe("Acme Team"); + }); + + it("rejects case-only duplicate organization names for the same owner", async () => { + const [owner, otherOwner] = await tdb + .insert(user) + .values([ + { + id: crypto.randomUUID(), + name: "Owner", + email: `owner-${crypto.randomUUID()}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: crypto.randomUUID(), + name: "Other Owner", + email: `other-owner-${crypto.randomUUID()}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .returning(); + + await createOrganization(owner.id, "Acme"); + await expect(createOrganization(owner.id, " acME ")).rejects.toThrow( + "organization_name_already_exists", + ); + await expect( + createOrganization(otherOwner.id, "ACME"), + ).resolves.toBeTruthy(); + }); + it("owns teams through a Better Auth user membership", async () => { const [member] = await tdb .insert(user) @@ -302,4 +367,169 @@ describe("organizations", () => { espConfigId: esp.id, }); }); + + it("blocks organization close while a live checkout attempt exists", async () => { + const [owner] = await tdb + .insert(user) + .values({ + id: crypto.randomUUID(), + name: "Owner", + email: `owner-${crypto.randomUUID()}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + const organization = await createOrganization(owner.id, "Acme"); + const [price] = await tdb + .insert(billingPriceEntries) + .values({ + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + currency: "USD", + amountMinor: 4900, + provider: "dodo", + providerProductId: `pdt_${crypto.randomUUID()}`, + }) + .returning(); + await tdb.insert(billingCheckoutAttempts).values({ + organizationId: organization.id, + payerUserId: owner.id, + provider: "dodo", + catalogRevision: 1, + catalogKey: "pro_month", + requestedPlan: "pro", + requestedInterval: "month", + billingPriceEntryId: price.id, + quotedAmountMinor: 4900, + quotedCurrency: "USD", + idempotencyKey: `checkout:${organization.id}`, + status: "open", + expiresAt: new Date(Date.now() + 60_000), + }); + + await expect(closeOrganization(organization.id)).rejects.toThrow( + "billing_checkout_pending", + ); + const [row] = await tdb + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, organization.id)); + expect(row?.status).toBe("active"); + }); +}); + +describe("one owned Free organization", () => { + const originalMode = process.env.SENDLIT_DEPLOYMENT_MODE; + afterEach(() => { + if (originalMode === undefined) + delete process.env.SENDLIT_DEPLOYMENT_MODE; + else process.env.SENDLIT_DEPLOYMENT_MODE = originalMode; + }); + + async function seedOwner() { + const [owner] = await tdb + .insert(user) + .values({ + id: crypto.randomUUID(), + name: "Owner", + email: `owner-${crypto.randomUUID()}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + return owner; + } + + async function attachSubscription( + organizationId: string, + ownerId: string, + input: { + status: "active" | "cancelled"; + cancelAtPeriodEnd: boolean; + paidThroughAt: Date; + }, + ) { + const [price] = await tdb + .insert(billingPriceEntries) + .values({ + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + currency: "USD", + amountMinor: 4900, + provider: "dodo", + providerProductId: `pdt_${crypto.randomUUID()}`, + }) + .returning(); + const [customer] = await tdb + .insert(billingProviderCustomers) + .values({ + provider: "dodo", + userId: ownerId, + providerCustomerId: `cus_${crypto.randomUUID()}`, + idempotencyKey: `customer:dodo:${ownerId}`, + status: "active", + }) + .returning(); + const [subscription] = await tdb + .insert(organizationSubscriptions) + .values({ + organizationId, + billingCustomerId: customer.id, + billingManagerUserId: ownerId, + provider: "dodo", + providerSubscriptionId: `sub_${crypto.randomUUID()}`, + providerProductId: price.providerProductId, + billingPriceEntryId: price.id, + catalogKey: "pro_month", + plan: "pro", + billingInterval: "month", + status: input.status, + paidThroughAt: input.paidThroughAt, + cancelAtPeriodEnd: input.cancelAtPeriodEnd, + isEntitlementSource: true, + }) + .returning(); + await tdb + .update(organizationPlanStates) + .set({ + plan: "pro", + activeSubscriptionId: subscription.id, + }) + .where(eq(organizationPlanStates.organizationId, organizationId)); + return subscription; + } + + it("treats an elapsed scheduled cancellation as Free even if the plan projection is stale", async () => { + process.env.SENDLIT_DEPLOYMENT_MODE = "cloud"; + const owner = await seedOwner(); + const paid = await createOrganization(owner.id, "Paid"); + await attachSubscription(paid.id, owner.id, { + status: "cancelled", + cancelAtPeriodEnd: true, + paidThroughAt: new Date(Date.now() - 60_000), + }); + + await expect(createOrganization(owner.id, "Second")).rejects.toThrow( + "free_organization_already_owned", + ); + }); + + it("allows another Free organization while scheduled cancellation still has paid access", async () => { + process.env.SENDLIT_DEPLOYMENT_MODE = "cloud"; + const owner = await seedOwner(); + const paid = await createOrganization(owner.id, "Paid"); + await attachSubscription(paid.id, owner.id, { + status: "cancelled", + cancelAtPeriodEnd: true, + paidThroughAt: new Date(Date.now() + 60_000), + }); + + await expect( + createOrganization(owner.id, "Second"), + ).resolves.toMatchObject({ name: "Second" }); + }); }); diff --git a/apps/api/src/organization/queries.ts b/apps/api/src/organization/queries.ts index 6ea5fc3..18a0003 100644 --- a/apps/api/src/organization/queries.ts +++ b/apps/api/src/organization/queries.ts @@ -1,21 +1,124 @@ -import { and, eq, isNull, ne } from "drizzle-orm"; +import { and, asc, eq, gt, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { db } from "../db/client"; import { organizationMembers, + billingCheckoutAttempts, organizationApiKeys, organizationDeliveryPolicies, espConfigTeamGrants, organizations, + organizationPlanStates, + organizationSubscriptions, + settings, teams, + teamDeliverySettings, + teamMembers, user, } from "../db/schema"; import { recordOrganizationAuditEvent } from "./audit"; import { transitionEspGrant } from "../delivery/queries"; import { findUserByEmail } from "../user/queries"; +import { ensureOrganizationPlanState } from "../billing/entitlements"; +import { resolveEntitlements } from "../billing/policies"; +import { defaultTeamName } from "./default-team-name"; export type Organization = typeof organizations.$inferSelect; export type OrganizationMember = typeof organizationMembers.$inferSelect; export type OrganizationRole = "owner" | "admin" | "member"; +type Transaction = Parameters[0]>[0]; + +/** Billing mutations lock live subscription rows before the organization so + * they cannot deadlock with checkout, plan-change, or webhook projection. */ +async function lockOrganizationSubscriptions( + tx: Transaction, + organizationId: string, +) { + return tx + .select({ id: organizationSubscriptions.id }) + .from(organizationSubscriptions) + .where(eq(organizationSubscriptions.organizationId, organizationId)) + .orderBy(asc(organizationSubscriptions.id)) + .for("update"); +} + +export async function userOwnsFreeOrganization( + userId: string, +): Promise { + return db.transaction((tx) => ownsEffectiveFreeOrganization(tx, userId)); +} + +async function ownsEffectiveFreeOrganization( + tx: Transaction, + userId: string, + now = new Date(), +): Promise { + const owned = await tx + .select({ + planState: organizationPlanStates, + subscription: organizationSubscriptions, + }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .innerJoin( + organizationPlanStates, + eq(organizationPlanStates.organizationId, organizations.id), + ) + .leftJoin( + organizationSubscriptions, + and( + eq(organizationSubscriptions.organizationId, organizations.id), + eq( + organizationSubscriptions.id, + organizationPlanStates.activeSubscriptionId, + ), + ), + ) + .where( + and( + eq(organizationMembers.userId, userId), + eq(organizationMembers.role, "owner"), + eq(organizations.status, "active"), + ), + ); + return owned.some(({ planState, subscription }) => { + const entitlements = resolveEntitlements({ + organizationId: planState.organizationId, + deploymentMode: + process.env.SENDLIT_DEPLOYMENT_MODE === "cloud" + ? "cloud" + : "oss", + planState: { + plan: planState.plan as "free" | "pro" | "business", + teamsLimitOverride: planState.teamsLimitOverride, + contactsLimitOverride: planState.contactsLimitOverride, + }, + subscription: subscription + ? { + plan: subscription.plan as "pro" | "business", + billingInterval: subscription.billingInterval as + "month" | "year", + status: subscription.status as + | "pending" + | "trialing" + | "active" + | "past_due" + | "cancelled" + | "expired", + currentPeriodEndsAt: subscription.currentPeriodEndsAt, + paidThroughAt: subscription.paidThroughAt, + trialEndsAt: subscription.trialEndsAt, + graceEndsAt: subscription.graceEndsAt, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + } + : null, + now, + }); + return entitlements.plan === "free"; + }); +} export async function getOrganization( id: string, @@ -66,18 +169,96 @@ export async function listOrganizationsForUser( organizations, eq(organizations.id, organizationMembers.organizationId), ) - .where(eq(organizationMembers.userId, userId)); + .where( + and( + eq(organizationMembers.userId, userId), + inArray(organizations.status, [ + "pending_payment", + "active", + "suspended", + ]), + ), + ); return rows.map((row) => row.organization); } export async function createOrganization( userId: string, name: string, + options: { + createInitialTeam?: boolean; + /** Used only by the paid-organization checkout. Pending rows do not + * consume the user's one owned Free organization and cannot create a + * team until a verified payment event activates them. */ + pendingPayment?: boolean; + } = {}, ): Promise { return db.transaction(async (tx) => { + const [identity] = await tx + .select({ id: user.id }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + .for("update"); + if (!identity) throw new Error("user_not_found"); + const normalizedName = name.trim(); + if (!normalizedName) throw new Error("organization_name_required"); + if (options.pendingPayment && options.createInitialTeam) { + throw new Error("pending_organization_cannot_create_team"); + } + if (options.pendingPayment) { + const [pending] = await tx + .select({ id: organizations.id }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + and( + eq(organizationMembers.userId, userId), + eq(organizationMembers.role, "owner"), + eq(organizations.status, "pending_payment"), + ), + ) + .limit(1); + if (pending) throw new Error("pending_organization_exists"); + } + const [existingName] = await tx + .select({ id: organizations.id }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + and( + eq(organizationMembers.userId, userId), + eq(organizationMembers.role, "owner"), + inArray(organizations.status, [ + "pending_payment", + "active", + "suspended", + ]), + sql`lower(trim(${organizations.name})) = lower(trim(${normalizedName}))`, + ), + ) + .limit(1); + if (existingName) throw new Error("organization_name_already_exists"); + if ( + process.env.SENDLIT_DEPLOYMENT_MODE === "cloud" && + !options.pendingPayment + ) { + if (await ownsEffectiveFreeOrganization(tx, userId)) { + throw new Error("free_organization_already_owned"); + } + } const [organization] = await tx .insert(organizations) - .values({ name }) + .values({ + name: normalizedName, + status: options.pendingPayment ? "pending_payment" : "active", + }) .returning(); await tx.insert(organizationMembers).values({ organizationId: organization.id, @@ -87,6 +268,23 @@ export async function createOrganization( await tx.insert(organizationDeliveryPolicies).values({ organizationId: organization.id, }); + await ensureOrganizationPlanState(tx, organization.id); + if (options.createInitialTeam) { + const [team] = await tx + .insert(teams) + .values({ + organizationId: organization.id, + name: defaultTeamName(organization.name), + }) + .returning(); + await tx.insert(settings).values({ teamId: team.id }); + await tx.insert(teamDeliverySettings).values({ teamId: team.id }); + await tx.insert(teamMembers).values({ + teamId: team.id, + userId, + role: "admin", + }); + } await recordOrganizationAuditEvent(tx, { organizationId: organization.id, actor: { type: "user", id: userId }, @@ -99,18 +297,120 @@ export async function createOrganization( export async function updateOrganizationName( organizationId: string, name: string, + actorUserId?: string, ): Promise { - const [row] = await db - .update(organizations) - .set({ name, updatedAt: new Date() }) - .where( - and( - eq(organizations.id, organizationId), - eq(organizations.status, "active"), - ), - ) - .returning(); - return row ?? null; + const normalizedName = name.trim(); + if (!normalizedName) throw new Error("organization_name_required"); + return db.transaction(async (tx) => { + if (actorUserId) { + await tx + .select({ id: user.id }) + .from(user) + .where(eq(user.id, actorUserId)) + .limit(1) + .for("update"); + } + const [current] = await tx + .select() + .from(organizations) + .where( + and( + eq(organizations.id, organizationId), + eq(organizations.status, "active"), + ), + ) + .limit(1) + .for("update"); + if (!current) return null; + if (actorUserId) { + const [duplicate] = await tx + .select({ id: organizations.id }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + and( + eq(organizationMembers.userId, actorUserId), + eq(organizationMembers.role, "owner"), + ne(organizations.id, organizationId), + inArray(organizations.status, [ + "pending_payment", + "active", + "suspended", + ]), + sql`lower(trim(${organizations.name})) = lower(trim(${normalizedName}))`, + ), + ) + .limit(1); + if (duplicate) throw new Error("organization_name_already_exists"); + } + const [row] = await tx + .update(organizations) + .set({ name: normalizedName, updatedAt: new Date() }) + .where(eq(organizations.id, organizationId)) + .returning(); + return row ?? null; + }); +} + +export async function abandonPendingOrganization( + organizationId: string, + actorUserId: string, +): Promise { + await db.transaction(async (tx) => { + const [organization] = await tx + .select() + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1) + .for("update"); + if (!organization) throw new Error("organization_not_found"); + if (organization.status !== "pending_payment") { + throw new Error("organization_not_pending_payment"); + } + const [membership] = await tx + .select({ role: organizationMembers.role }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, organizationId), + eq(organizationMembers.userId, actorUserId), + ), + ) + .limit(1); + if (membership?.role !== "owner") { + throw new Error("organization_owner_required"); + } + await tx + .update(billingCheckoutAttempts) + .set({ + status: "abandoned", + checkoutUrlEncrypted: null, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(billingCheckoutAttempts.organizationId, organizationId), + inArray(billingCheckoutAttempts.status, [ + "creating", + "open", + ]), + ), + ); + await tx + .update(organizations) + .set({ status: "abandoned", updatedAt: new Date() }) + .where(eq(organizations.id, organizationId)); + await recordOrganizationAuditEvent(tx, { + organizationId, + actor: { type: "user", id: actorUserId }, + action: "organization.pending_abandoned", + metadata: {}, + }); + }); } export async function closeOrganization( @@ -123,6 +423,66 @@ export async function closeOrganization( }, ): Promise { await db.transaction(async (tx) => { + const liveSubscriptions = await tx + .select({ id: organizationSubscriptions.id }) + .from(organizationSubscriptions) + .where( + and( + eq( + organizationSubscriptions.organizationId, + organizationId, + ), + or( + inArray(organizationSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + ]), + and( + eq(organizationSubscriptions.status, "cancelled"), + eq( + organizationSubscriptions.cancelAtPeriodEnd, + true, + ), + gt( + organizationSubscriptions.paidThroughAt, + new Date(), + ), + ), + ), + ), + ) + .orderBy(asc(organizationSubscriptions.id)) + .for("update"); + if (liveSubscriptions.length > 0) { + throw new Error("active_subscription_exists"); + } + const openCheckouts = await tx + .select({ id: billingCheckoutAttempts.id }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.organizationId, organizationId), + inArray(billingCheckoutAttempts.status, [ + "creating", + "open", + ]), + gt(billingCheckoutAttempts.expiresAt, new Date()), + ), + ) + .orderBy(asc(billingCheckoutAttempts.id)) + .for("update"); + if (openCheckouts.length > 0) { + throw new Error("billing_checkout_pending"); + } + const [organization] = await tx + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1) + .for("update"); + if (!organization) throw new Error("organization_not_found"); await tx .update(organizations) .set({ status: "closed", updatedAt: new Date() }) @@ -217,10 +577,24 @@ export async function addOrganizationMemberByEmail( ) { const identity = await findUserByEmail(email); if (!identity) return null; - await db.insert(organizationMembers).values({ - organizationId, - userId: identity.id, - role, + await db.transaction(async (tx) => { + await tx + .select({ id: user.id }) + .from(user) + .where(eq(user.id, identity.id)) + .limit(1) + .for("update"); + if ( + role === "owner" && + process.env.SENDLIT_DEPLOYMENT_MODE === "cloud" + ) { + if (await ownsEffectiveFreeOrganization(tx, identity.id)) { + throw new Error("free_organization_already_owned"); + } + } + await tx + .insert(organizationMembers) + .values({ organizationId, userId: identity.id, role }); }); return getOrganizationMemberView(organizationId, identity.id); } @@ -245,12 +619,51 @@ async function assertNotLastOwner( } } +async function assertBillingManagerRetained( + tx: Parameters[0]>[0], + organizationId: string, + userId: string, +): Promise { + const [subscription] = await tx + .select({ id: organizationSubscriptions.id }) + .from(organizationSubscriptions) + .where( + and( + eq(organizationSubscriptions.organizationId, organizationId), + eq(organizationSubscriptions.billingManagerUserId, userId), + or( + inArray(organizationSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + ]), + and( + eq(organizationSubscriptions.status, "cancelled"), + eq(organizationSubscriptions.cancelAtPeriodEnd, true), + gt(organizationSubscriptions.paidThroughAt, new Date()), + ), + ), + ), + ) + .limit(1) + .for("update"); + if (subscription) throw new Error("billing_manager_required"); +} + export async function updateOrganizationMemberRole( organizationId: string, userId: string, role: OrganizationRole, ) { await db.transaction(async (tx) => { + await lockOrganizationSubscriptions(tx, organizationId); + await tx + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1) + .for("update"); const [membership] = await tx .select() .from(organizationMembers) @@ -263,9 +676,26 @@ export async function updateOrganizationMemberRole( .limit(1) .for("update"); if (!membership) throw new Error("member_not_found"); + if ( + membership.role !== "owner" && + role === "owner" && + process.env.SENDLIT_DEPLOYMENT_MODE === "cloud" + ) { + await tx + .select({ id: user.id }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + .for("update"); + if (await ownsEffectiveFreeOrganization(tx, userId)) { + throw new Error("free_organization_already_owned"); + } + } if (membership.role === "owner" && role !== "owner") { await assertNotLastOwner(tx, organizationId, userId); } + if (role !== "owner") + await assertBillingManagerRetained(tx, organizationId, userId); await tx .update(organizationMembers) .set({ role, updatedAt: new Date() }) @@ -279,6 +709,13 @@ export async function removeOrganizationMember( userId: string, ): Promise { return db.transaction(async (tx) => { + await lockOrganizationSubscriptions(tx, organizationId); + await tx + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1) + .for("update"); const [membership] = await tx .select() .from(organizationMembers) @@ -294,6 +731,7 @@ export async function removeOrganizationMember( if (membership.role === "owner") { await assertNotLastOwner(tx, organizationId, userId); } + await assertBillingManagerRetained(tx, organizationId, userId); await tx .delete(organizationMembers) .where(eq(organizationMembers.id, membership.id)); @@ -324,7 +762,92 @@ export async function ensureDefaultOrganization( .from(organizations) .where(eq(organizations.id, identity.defaultOrganizationId)) .limit(1); - if (existing) return existing; + if (existing && ["active", "suspended"].includes(existing.status)) { + const [team] = await tx + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.organizationId, existing.id)) + .limit(1); + if (!team) { + const [createdTeam] = await tx + .insert(teams) + .values({ + organizationId: existing.id, + name: defaultTeamName(existing.name), + }) + .returning(); + await tx + .insert(settings) + .values({ teamId: createdTeam.id }); + await tx + .insert(teamDeliverySettings) + .values({ teamId: createdTeam.id }); + await tx.insert(teamMembers).values({ + teamId: createdTeam.id, + userId: identity.id, + role: "admin", + }); + } + return existing; + } + } + + // Auth bootstrap can run for users imported from an existing + // organization graph. Reuse that membership instead of creating a + // second organization (and, consequently, a second default team). + const [existingMembership] = await tx + .select({ organization: organizations }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + and( + eq(organizationMembers.userId, identity.id), + inArray(organizations.status, ["active", "suspended"]), + ), + ) + .limit(1); + if (existingMembership) { + await tx + .update(user) + .set({ + defaultOrganizationId: existingMembership.organization.id, + updatedAt: new Date(), + }) + .where(eq(user.id, identity.id)); + const [team] = await tx + .select({ id: teams.id }) + .from(teams) + .where( + eq( + teams.organizationId, + existingMembership.organization.id, + ), + ) + .limit(1); + if (!team) { + const [createdTeam] = await tx + .insert(teams) + .values({ + organizationId: existingMembership.organization.id, + name: defaultTeamName( + existingMembership.organization.name, + ), + }) + .returning(); + await tx.insert(settings).values({ teamId: createdTeam.id }); + await tx + .insert(teamDeliverySettings) + .values({ teamId: createdTeam.id }); + await tx.insert(teamMembers).values({ + teamId: createdTeam.id, + userId: identity.id, + role: "admin", + }); + } + return existingMembership.organization; } const organizationName = identity.name.trim() @@ -342,6 +865,19 @@ export async function ensureDefaultOrganization( await tx.insert(organizationDeliveryPolicies).values({ organizationId: organization.id, }); + await ensureOrganizationPlanState(tx, organization.id); + const [team] = await tx + .insert(teams) + .values({ + organizationId: organization.id, + name: defaultTeamName(organization.name), + }) + .returning(); + await tx.insert(settings).values({ teamId: team.id }); + await tx.insert(teamDeliverySettings).values({ teamId: team.id }); + await tx + .insert(teamMembers) + .values({ teamId: team.id, userId: identity.id, role: "admin" }); await tx .update(user) .set({ diff --git a/apps/api/src/organization/routes.ts b/apps/api/src/organization/routes.ts index 550ff58..258ad6e 100644 --- a/apps/api/src/organization/routes.ts +++ b/apps/api/src/organization/routes.ts @@ -4,7 +4,9 @@ import { contract } from "@sendlit/api-contract"; import { requireAuth } from "../auth/middleware"; import { addOrganizationMemberByEmail, + abandonPendingOrganization, closeOrganization, + userOwnsFreeOrganization, createOrganization, getOrganizationByPublicId, getOrganizationMembership, @@ -68,6 +70,19 @@ import { getSiteUrl } from "../utils/mail"; import { listOrganizationAuditEvents } from "./audit"; import { recordOrganizationAuditEvent } from "./audit"; import { db } from "../db/client"; +import { + assertCapability, + getOrganizationEntitlements, +} from "../billing/entitlements"; +import { isPlanGateError, planGateHttp } from "../billing/errors"; +import { + createSendingDomain, + listSendingDomains, + revokeSendingDomain, + serializeSendingDomain, + verifySendingDomain, +} from "../billing/domains"; +import { requireBillingAction } from "../billing/security"; const router = Router(); router.use("/organizations", requireAuth); @@ -77,7 +92,8 @@ function serializeOrganization(organization: Organization) { return { organizationId: organization.organizationId, name: organization.name, - status: organization.status as "active" | "suspended" | "closed", + status: organization.status as + "pending_payment" | "active" | "suspended" | "abandoned" | "closed", createdAt: organization.createdAt.toISOString(), updatedAt: organization.updatedAt.toISOString(), }; @@ -257,6 +273,24 @@ function mayManageOrganizationEsp( ); } +async function sharedMailboxGate(organizationId: string) { + try { + assertCapability( + await getOrganizationEntitlements(organizationId), + "shared_organization_mailbox", + ); + return null; + } catch (error) { + if (isPlanGateError(error)) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; + } + throw error; + } +} + function serializeDeliveryPolicy( view: NonNullable< Awaited> @@ -352,7 +386,12 @@ const impl = s.router(contract.organizations, { ); return { status: 200, - body: { items: organizations.map(serializeOrganization) }, + body: { + items: organizations.map(serializeOrganization), + ownsFreeOrganization: await userOwnsFreeOrganization( + (req as any).userId, + ), + }, }; }, create: async ({ req, body }) => { @@ -362,11 +401,28 @@ const impl = s.router(contract.organizations, { body: { error: "user_auth_required" }, }; } - const organization = await createOrganization( - (req as any).userId, - body.name, - ); - return { status: 201, body: serializeOrganization(organization) }; + try { + const organization = await createOrganization( + (req as any).userId, + body.name, + { createInitialTeam: true }, + ); + return { status: 201, body: serializeOrganization(organization) }; + } catch (error: any) { + if (error?.message === "organization_name_already_exists") { + return { + status: 409, + body: { error: "organization_name_already_exists" }, + }; + } + if (error?.message === "free_organization_already_owned") { + return { + status: 409, + body: { error: "free_organization_already_owned" }, + }; + } + throw error; + } }, get: async ({ req, params }) => { const authorization = await resolveAuthorization( @@ -404,10 +460,34 @@ const impl = s.router(contract.organizations, { body: { error: "organization_permission_required" }, }; } - const updated = await updateOrganizationName( - authorization.organization.id, - body.name, - ); + let updated: Organization | null; + try { + updated = await updateOrganizationName( + authorization.organization.id, + body.name, + (req as any).userId, + ); + } catch (error) { + if ( + error instanceof Error && + error.message === "organization_name_already_exists" + ) { + return { + status: 409, + body: { error: "organization_name_already_exists" }, + }; + } + if ( + error instanceof Error && + error.message === "organization_name_required" + ) { + return { + status: 400, + body: { error: "organization_name_required" }, + }; + } + throw error; + } if (!updated) { return { status: 404, body: { error: "organization_not_found" } }; } @@ -433,10 +513,70 @@ const impl = s.router(contract.organizations, { body: { error: "organization_owner_required" }, }; } - await closeOrganization(authorization.organization.id, { - type: "user", - id: (req as any).userId, - }); + const boundary = await requireBillingAction( + req, + req.res, + "organization_close", + params.organizationId, + ); + if (boundary) return boundary as any; + try { + await closeOrganization(authorization.organization.id, { + type: "user", + id: (req as any).userId, + }); + } catch (error: any) { + if (error?.message === "active_subscription_exists") { + return { + status: 409, + body: { error: "active_subscription_exists" }, + }; + } + if (error?.message === "billing_checkout_pending") { + return { + status: 409, + body: { error: "billing_checkout_pending" }, + }; + } + throw error; + } + return { status: 204, body: undefined }; + }, + abandon: async ({ req, params }) => { + const authorization = await resolveAuthorization( + req, + params.organizationId, + ); + if (!authorization) { + return { status: 404, body: { error: "organization_not_found" } }; + } + if (!hasRole(authorization, ["owner"])) { + return { + status: 403, + body: { error: "organization_owner_required" }, + }; + } + const boundary = await requireBillingAction( + req, + req.res, + "pending_hide", + params.organizationId, + ); + if (boundary) return boundary as any; + try { + await abandonPendingOrganization( + authorization.organization.id, + (req as any).userId, + ); + } catch (error: any) { + if (error?.message === "organization_not_pending_payment") { + return { + status: 409, + body: { error: "organization_not_pending_payment" }, + }; + } + throw error; + } return { status: 204, body: undefined }; }, listMembers: async ({ req, params }) => { @@ -500,6 +640,12 @@ const impl = s.router(contract.organizations, { if (error?.code === "23505") { return { status: 409, body: { error: "member_exists" } }; } + if (error?.message === "free_organization_already_owned") { + return { + status: 409, + body: { error: "free_organization_already_owned" }, + }; + } throw error; } }, @@ -550,6 +696,18 @@ const impl = s.router(contract.organizations, { body: { error: "last_organization_owner" }, }; } + if (error?.message === "free_organization_already_owned") { + return { + status: 409, + body: { error: "free_organization_already_owned" }, + }; + } + if (error?.message === "billing_manager_required") { + return { + status: 409, + body: { error: "billing_manager_required" }, + }; + } throw error; } }, @@ -598,6 +756,12 @@ const impl = s.router(contract.organizations, { body: { error: "last_organization_owner" }, }; } + if (error?.message === "billing_manager_required") { + return { + status: 409, + body: { error: "billing_manager_required" }, + }; + } throw error; } }, @@ -666,6 +830,8 @@ const impl = s.router(contract.organizations, { ); return { status: 201, body: serializeTeam(team) }; } catch (error: any) { + const gated = planGateHttp(error); + if (gated) return gated as any; return { status: 409, body: { error: error.message } }; } }, @@ -770,6 +936,22 @@ const impl = s.router(contract.organizations, { body: { error: "organization_owner_required" }, }; } + try { + assertCapability( + await getOrganizationEntitlements( + authorization.organization.id, + ), + "organization_api_keys", + ); + } catch (error) { + if (isPlanGateError(error)) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; + } + throw error; + } const { apiKey, secret } = await createOrganizationApiKey( authorization.organization.id, body.name, @@ -859,6 +1041,8 @@ const impl = s.router(contract.organizations, { body: { error: "organization_esp_permission_required" }, }; } + const gate = await sharedMailboxGate(authorization.organization.id); + if (gate) return gate; const config = await createOrganizationEspConfig( authorization.organization.id, body, @@ -906,6 +1090,10 @@ const impl = s.router(contract.organizations, { body: { error: "organization_esp_permission_required" }, }; } + const updateGate = await sharedMailboxGate( + authorization.organization.id, + ); + if (updateGate) return updateGate; try { const config = await updateOrganizationEspConfig( authorization.organization.id, @@ -945,6 +1133,8 @@ const impl = s.router(contract.organizations, { body: { error: "organization_esp_permission_required" }, }; } + const testGate = await sharedMailboxGate(authorization.organization.id); + if (testGate) return testGate; const config = await getOrganizationEspConfigByEspId( authorization.organization.id, params.espId, @@ -990,6 +1180,10 @@ const impl = s.router(contract.organizations, { body: { error: "organization_esp_permission_required" }, }; } + const activateGate = await sharedMailboxGate( + authorization.organization.id, + ); + if (activateGate) return activateGate; const config = await getOrganizationEspConfigByEspId( authorization.organization.id, params.espId, @@ -1083,6 +1277,10 @@ const impl = s.router(contract.organizations, { body: { error: "organization_esp_permission_required" }, }; } + const resumeGate = await sharedMailboxGate( + authorization.organization.id, + ); + if (resumeGate) return resumeGate; const config = await getOrganizationEspConfigByEspId( authorization.organization.id, params.espId, @@ -1232,6 +1430,8 @@ const impl = s.router(contract.organizations, { status: 403, body: { error: "organization_esp_permission_required" }, }; + const gate = await sharedMailboxGate(authorization.organization.id); + if (gate) return gate; const esp = await getOrganizationEspConfigByEspId( authorization.organization.id, params.espId, @@ -1270,6 +1470,8 @@ const impl = s.router(contract.organizations, { status: 403, body: { error: "organization_esp_permission_required" }, }; + const gate = await sharedMailboxGate(authorization.organization.id); + if (gate) return gate; const esp = await getOrganizationEspConfigByEspId( authorization.organization.id, params.espId, @@ -1400,6 +1602,10 @@ const impl = s.router(contract.organizations, { body: { error: "organization_permission_required" }, }; } + if (body.defaultEspId || body.autoGrantDefaultEsp) { + const gate = await sharedMailboxGate(authorization.organization.id); + if (gate) return gate; + } try { const view = await updateOrganizationDeliveryPolicy( authorization.organization.id, @@ -1641,6 +1847,8 @@ const impl = s.router(contract.organizations, { body: { error: "organization_permission_required" }, }; } + const gate = await sharedMailboxGate(authorization.organization.id); + if (gate) return gate; const team = await getTeamByTeamId(params.teamId); if (!team || team.organizationId !== authorization.organization.id) { return { status: 404, body: { error: "team_not_found" } }; @@ -1739,6 +1947,108 @@ const impl = s.router(contract.organizations, { }; } }, + listSendingDomains: async ({ req, params }: any) => { + const authorization = await resolveAuthorization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + if (!hasRole(authorization, ["owner", "admin"])) + return { + status: 403, + body: { error: "organization_permission_required" }, + }; + return { + status: 200, + body: { + items: ( + await listSendingDomains(authorization.organization.id) + ).map((row) => serializeSendingDomain(row)), + }, + }; + }, + createSendingDomain: async ({ req, params, body }: any) => { + const authorization = await resolveAuthorization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + if (!hasRole(authorization, ["owner", "admin"])) + return { + status: 403, + body: { error: "organization_permission_required" }, + }; + try { + const result = await createSendingDomain( + authorization.organization.id, + body.domain, + ); + return { + status: 201, + body: serializeSendingDomain(result.row, result.token), + }; + } catch (error: any) { + if (error?.code === "23505") + return { status: 409, body: { error: "domain_exists" } }; + if ( + error?.message === "domain_invalid" || + error?.message === "domain_public_suffix" + ) + return { status: 400, body: { error: error.message } }; + throw error; + } + }, + verifySendingDomain: async ({ req, params }: any) => { + const authorization = await resolveAuthorization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + if (!hasRole(authorization, ["owner", "admin"])) + return { + status: 403, + body: { error: "organization_permission_required" }, + }; + const result = await verifySendingDomain( + authorization.organization.id, + params.domainId, + ); + if (!result) + return { status: 404, body: { error: "domain_not_found" } }; + return result.verified + ? { status: 200, body: serializeSendingDomain(result.row) } + : ({ + status: 422, + body: { + error: "domain_verification_pending", + ...serializeSendingDomain(result.row), + }, + } as any); + }, + revokeSendingDomain: async ({ req, params }: any) => { + const authorization = await resolveAuthorization( + req, + params.organizationId, + ); + if (!authorization) + return { status: 404, body: { error: "organization_not_found" } }; + if (!hasRole(authorization, ["owner", "admin"])) + return { + status: 403, + body: { error: "organization_permission_required" }, + }; + if ( + !(await revokeSendingDomain( + authorization.organization.id, + params.domainId, + )) + ) + return { status: 404, body: { error: "domain_not_found" } }; + return { status: 204, body: undefined }; + }, }); createExpressEndpoints(contract.organizations, impl, router); diff --git a/apps/api/src/provisioning/routes.ts b/apps/api/src/provisioning/routes.ts index 28f8b8b..679f403 100644 --- a/apps/api/src/provisioning/routes.ts +++ b/apps/api/src/provisioning/routes.ts @@ -20,6 +20,11 @@ import logger from "../services/log"; import { captureError, captureEvent } from "../observability/posthog"; import { requireAuth } from "../auth/middleware"; import { recordOrganizationAuditEvent } from "../organization/audit"; +import { + assertCapability, + getOrganizationEntitlements, +} from "../billing/entitlements"; +import { planGateHttp } from "../billing/errors"; const router = Router(); @@ -41,6 +46,13 @@ router.use("/provisioning", requireAuth); const s = initServer(); +async function requireProvisioningCapability(req: any) { + assertCapability( + await getOrganizationEntitlements(req.organizationId), + "provisioning", + ); +} + function hasScope(req: any, scope: string): boolean { return ( req.authKind === "organization_key" && @@ -166,6 +178,8 @@ const impl = s.router(contract.provisioning, { }, }; } catch (err: any) { + const gated = planGateHttp(err); + if (gated) return gated as any; if (err.message === "provisioning_conflict") { return { status: 409, @@ -205,6 +219,7 @@ const impl = s.router(contract.provisioning, { const team = await resolveProvisionedTeam(authReq, params.teamId); if (!team) return { status: 404, body: { error: "team_not_found" } }; try { + await requireProvisioningCapability(authReq); const updated = await updateProvisionedTeam(team.id, body); if (!updated) return { status: 404, body: { error: "team_not_found" } }; @@ -216,6 +231,8 @@ const impl = s.router(contract.provisioning, { ), }; } catch (error: any) { + const gated = planGateHttp(error); + if (gated) return gated as any; return { status: 409, body: { error: error.message } }; } }, @@ -228,6 +245,13 @@ const impl = s.router(contract.provisioning, { }; const team = await resolveProvisionedTeam(authReq, params.teamId); if (!team) return { status: 404, body: { error: "team_not_found" } }; + try { + await requireProvisioningCapability(authReq); + } catch (error) { + const gated = planGateHttp(error); + if (gated) return gated as any; + throw error; + } const result = await db.transaction(async (tx) => { await tx .update(teamApiKeys) @@ -275,6 +299,13 @@ const impl = s.router(contract.provisioning, { }; const team = await resolveProvisionedTeam(authReq, params.teamId); if (!team) return { status: 404, body: { error: "team_not_found" } }; + try { + await requireProvisioningCapability(authReq); + } catch (error) { + const gated = planGateHttp(error); + if (gated) return gated as any; + throw error; + } if (team.status !== "active") return { status: 409, @@ -301,6 +332,13 @@ const impl = s.router(contract.provisioning, { }; const team = await resolveProvisionedTeam(authReq, params.teamId); if (!team) return { status: 404, body: { error: "team_not_found" } }; + try { + await requireProvisioningCapability(authReq); + } catch (error) { + const gated = planGateHttp(error); + if (gated) return gated as any; + throw error; + } if (team.status !== "sending_suspended") return { status: 409, @@ -324,6 +362,13 @@ const impl = s.router(contract.provisioning, { }; const team = await resolveProvisionedTeam(authReq, params.teamId); if (!team) return { status: 404, body: { error: "team_not_found" } }; + try { + await requireProvisioningCapability(authReq); + } catch (error) { + const gated = planGateHttp(error); + if (gated) return gated as any; + throw error; + } await archiveTeam(team.id); await auditProvisioningAction(authReq, team, "team.archived"); return { status: 204, body: undefined }; diff --git a/apps/api/src/sequences/queries.ts b/apps/api/src/sequences/queries.ts index f1b7105..18452a5 100644 --- a/apps/api/src/sequences/queries.ts +++ b/apps/api/src/sequences/queries.ts @@ -36,6 +36,14 @@ import { resolveDeliverySource, type DeliverySourceSelection, } from "../delivery/queries"; +import { getTeam } from "../team/queries"; +import { + assertMarketingAllowedForContactUsage, + assertSendAllowedForTeam, + getOrganizationEntitlements, + PlanGateError, +} from "../billing/entitlements"; +import { usageForOrganization } from "../billing/usage"; export type Sequence = typeof sequences.$inferSelect; export type SequenceEmail = typeof sequenceEmails.$inferSelect; @@ -618,10 +626,18 @@ export async function startSequence({ throw new Error(responses.no_published_emails); } + await assertSendAllowedForTeam(teamId, "marketing"); + const team = await getTeam(teamId); + if (!team) throw new Error("team_not_found"); + await db.transaction(async (tx) => { + await assertMarketingAllowedForContactUsage(tx, team.organizationId); + }); + const pin = await resolveDeliverySource( teamId, (sequence.deliverySourceIntent as DeliverySourceSelection | null) ?? undefined, + "marketing", ); if (sequence.type === "sequence") { @@ -655,6 +671,26 @@ export async function startSequence({ if (recipientIds.length === 0) { throw new Error(responses.broadcast_no_recipients); } + const entitlements = await getOrganizationEntitlements( + team.organizationId, + ); + if (entitlements.monthlySendsLimit !== null) { + const usage = await usageForOrganization(team.organizationId); + const projected = + usage.monthlySends + + usage.monthlySendsReserved + + recipientIds.length; + if (projected > entitlements.monthlySendsLimit) { + throw new PlanGateError("plan_limit_reached", { + organizationId: team.organizationId, + capability: "monthly_sends", + limit: entitlements.monthlySendsLimit, + usage: usage.monthlySends + usage.monthlySendsReserved, + plan: entitlements.plan, + requiredPlan: "pro", + }); + } + } } const pinChanged = diff --git a/apps/api/src/sequences/routes.ts b/apps/api/src/sequences/routes.ts index 8ed928f..5fb72e5 100644 --- a/apps/api/src/sequences/routes.ts +++ b/apps/api/src/sequences/routes.ts @@ -25,6 +25,7 @@ import { import { serializeDates } from "../utils/serialize"; import { omitInternal } from "../utils/public"; import { MAILING_ADDRESS_REQUIRED } from "../settings/general/constants"; +import { planGateHttp } from "../billing/errors"; const router = Router(); router.use("/sequences", requireAuth, requireTeam); @@ -177,6 +178,8 @@ const impl = s.router(contract.sequences, { }); return { status: 200, body: toBody(sequence) }; } catch (err: any) { + const gated = planGateHttp(err); + if (gated) return gated as any; return err.message === MAILING_ADDRESS_REQUIRED ? { status: 422, diff --git a/apps/api/src/team/queries.ts b/apps/api/src/team/queries.ts index f5c0a1d..5665ee9 100644 --- a/apps/api/src/team/queries.ts +++ b/apps/api/src/team/queries.ts @@ -11,6 +11,11 @@ import { teamMembers, teams, } from "../db/schema"; +import { + assertCapability, + getOrganizationEntitlements, + reserveTeamSlot, +} from "../billing/entitlements"; import { transitionEspGrant } from "../delivery/queries"; import { createApiKey } from "../apikey/queries"; @@ -248,6 +253,7 @@ export async function createTeam({ withDefaultApiKey?: boolean; }): Promise { return db.transaction(async (tx) => { + await reserveTeamSlot(tx, organizationId); const [policy] = await tx .select() .from(organizationDeliveryPolicies) @@ -386,6 +392,10 @@ export async function findOrCreateTeamByExternalId({ } return existing; } + assertCapability( + await getOrganizationEntitlements(organizationId), + "provisioning", + ); // Provisioning's response body is the consumer's only way to receive the // key, so this path always mints one (unlike other `createTeam` callers). try { diff --git a/apps/api/src/team/routes.ts b/apps/api/src/team/routes.ts index d8c41f3..aba1d85 100644 --- a/apps/api/src/team/routes.ts +++ b/apps/api/src/team/routes.ts @@ -19,6 +19,7 @@ import { import { serializeDates } from "../utils/serialize"; import { getUser } from "../user/queries"; import { getOrganizationMembership } from "../organization/queries"; +import { isPlanGateError } from "../billing/errors"; const router = Router(); // This router is mounted at the API root. Scope account-level middleware to @@ -120,12 +121,22 @@ const impl = s.router(contract.teams, { body: { error: "organization_permission_required" }, } as const; } - const team = await createTeam({ - organizationId: identity.defaultOrganizationId, - creatorUserId: identity.id, - name: body.name, - }); - return { status: 201, body: serializeDates(toPublicTeam(team)) }; + try { + const team = await createTeam({ + organizationId: identity.defaultOrganizationId, + creatorUserId: identity.id, + name: body.name, + }); + return { status: 201, body: serializeDates(toPublicTeam(team)) }; + } catch (error) { + if (isPlanGateError(error)) { + return { + status: error.status, + body: { error: error.code, ...error.details }, + } as any; + } + throw error; + } }, rename: async ({ params, body, req }) => { const resolved = await resolveTeamParam( diff --git a/apps/api/src/test/db.ts b/apps/api/src/test/db.ts index a33f020..9821418 100644 --- a/apps/api/src/test/db.ts +++ b/apps/api/src/test/db.ts @@ -41,6 +41,9 @@ export async function truncateAll(db: Awaited>) { // email_deliveries, team_members, api_keys; sequences cascades to // sequence_emails. await db.delete(schema.mailDispatchOutbox); + await db.delete(schema.verification); + await db.delete(schema.planSendReservations); + await db.delete(schema.planSendUsageBuckets); await db.delete(schema.organizationEspQuotaReservations); await db.delete(schema.outboundMessages); await db.delete(schema.transactionalEmails); @@ -55,8 +58,20 @@ export async function truncateAll(db: Awaited>) { await db.delete(schema.organizationEspUsageBuckets); await db.delete(schema.espConfigTeamGrants); await db.delete(schema.teamDeliverySettings); + await db.delete(schema.teamSendingControls); await db.delete(schema.espConfigs); await db.delete(schema.teams); + await db.delete(schema.sendingDomains); + await db.delete(schema.billingTrialClaims); + await db.delete(schema.billingPlanChangeAttempts); + await db.delete(schema.billingCheckoutAttempts); + await db.delete(schema.organizationPlanStates); + await db.delete(schema.organizationSubscriptions); + await db.delete(schema.billingWebhookEvents); + await db.delete(schema.billingCatalogRevisionItems); + await db.delete(schema.billingCatalogRevisions); + await db.delete(schema.billingPriceEntries); + await db.delete(schema.billingProviderCustomers); await db.delete(schema.organizationApiKeys); await db.delete(schema.organizationAuditEvents); await db.delete(schema.organizationMembers); @@ -107,6 +122,10 @@ export async function seedTeamAndContact( await db.insert(schema.organizationDeliveryPolicies).values({ organizationId: organization.id, }); + await db.insert(schema.organizationPlanStates).values({ + organizationId: organization.id, + plan: "free", + }); const [team] = await db .insert(schema.teams) diff --git a/apps/api/src/test/setup.ts b/apps/api/src/test/setup.ts index 05f20f7..a724d9d 100644 --- a/apps/api/src/test/setup.ts +++ b/apps/api/src/test/setup.ts @@ -1,6 +1,9 @@ // Runs before each test file (see vitest.config.ts `setupFiles`). // `src/config/constants.ts` and `src/utils/pixel-jwt.ts` read these at // import/call time, so they must exist before any app module loads. +process.env.SENDLIT_DEPLOYMENT_MODE ||= "oss"; +process.env.BILLING_TRIAL_EMAIL_HMAC_KEY ||= + "test-trial-hmac-key-with-at-least-32-bytes"; process.env.PIXEL_SIGNING_SECRET ||= "test-pixel-secret"; process.env.PROTOCOL ||= "https"; process.env.DOMAIN ||= "sendlit.test"; diff --git a/apps/api/src/transactional/queries.ts b/apps/api/src/transactional/queries.ts index d4230e6..f9a5100 100644 --- a/apps/api/src/transactional/queries.ts +++ b/apps/api/src/transactional/queries.ts @@ -31,6 +31,7 @@ import { import { generateRfcMessageId } from "../utils/rfc-message-id"; import { getActiveFeedbackConnectionForEspConfig } from "../delivery-feedback/feedback-connection-queries"; import { reserveOrganizationQuota } from "../delivery/quota"; +import { reserveSend } from "../billing/entitlements"; export type TransactionalEmail = typeof transactionalEmails.$inferSelect; export type { TransactionalEmailStatus }; @@ -268,7 +269,11 @@ export async function createTransactionalEmail({ const team = await getTeam(teamId); if (!team) throw new Error("esp_not_configured"); - const pin = await resolveDeliverySource(teamId, deliverySource); + const pin = await resolveDeliverySource( + teamId, + deliverySource, + "transactional", + ); let renderedHtml: string; let resolvedTemplateId: string | null = null; @@ -403,6 +408,11 @@ export async function createTransactionalEmail({ rfcMessageId: generateRfcMessageId(), }) .returning(); + await reserveSend(tx, { + organizationId: team.organizationId, + outboundMessageId: outbound.id, + purpose: "transactional", + }); if (pin.type === "organization") { await reserveOrganizationQuota(tx, { outboundMessageId: outbound.id, diff --git a/apps/api/src/transactional/routes.ts b/apps/api/src/transactional/routes.ts index e00501f..0c3419f 100644 --- a/apps/api/src/transactional/routes.ts +++ b/apps/api/src/transactional/routes.ts @@ -16,6 +16,7 @@ import { MISSING_TEMPLATE_VARIABLES, MissingTemplateVariablesError, } from "../mail/render"; +import { isPlanGateError } from "../billing/errors"; const router = Router(); router.use("/emails", requireAuth, requireTeam); @@ -86,6 +87,12 @@ const impl = s.router(contract.transactional, { body: { txeId: row.txeId, status: row.status as any }, }; } catch (err: any) { + if (isPlanGateError(err)) { + return { + status: err.status, + body: { error: err.code, ...err.details }, + } as any; + } if (err instanceof MissingTemplateVariablesError) { return { status: 422, diff --git a/apps/docs/.source/browser.ts b/apps/docs/.source/browser.ts index e98216e..20e0564 100644 --- a/apps/docs/.source/browser.ts +++ b/apps/docs/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "activity/suppressions.mdx": () => import("../content/docs/activity/suppressions.mdx?collection=docs"), "activity/transactional.mdx": () => import("../content/docs/activity/transactional.mdx?collection=docs"), "developers/api-keys.mdx": () => import("../content/docs/developers/api-keys.mdx?collection=docs"), "developers/authentication.mdx": () => import("../content/docs/developers/authentication.mdx?collection=docs"), "developers/errors.mdx": () => import("../content/docs/developers/errors.mdx?collection=docs"), "developers/mcp.mdx": () => import("../content/docs/developers/mcp.mdx?collection=docs"), "developers/overview.mdx": () => import("../content/docs/developers/overview.mdx?collection=docs"), "developers/provisioning.mdx": () => import("../content/docs/developers/provisioning.mdx?collection=docs"), "developers/transactional.mdx": () => import("../content/docs/developers/transactional.mdx?collection=docs"), "email-blocks/contact-filter-builder.mdx": () => import("../content/docs/email-blocks/contact-filter-builder.mdx?collection=docs"), "email-blocks/email-editor.mdx": () => import("../content/docs/email-blocks/email-editor.mdx?collection=docs"), "email-blocks/email-preview.mdx": () => import("../content/docs/email-blocks/email-preview.mdx?collection=docs"), "email-blocks/getting-started.mdx": () => import("../content/docs/email-blocks/getting-started.mdx?collection=docs"), "email-blocks/overview.mdx": () => import("../content/docs/email-blocks/overview.mdx?collection=docs"), "email-blocks/sequence-analytics.mdx": () => import("../content/docs/email-blocks/sequence-analytics.mdx?collection=docs"), "email-blocks/sequence-email-list.mdx": () => import("../content/docs/email-blocks/sequence-email-list.mdx?collection=docs"), "email-blocks/subscriber-list.mdx": () => import("../content/docs/email-blocks/subscriber-list.mdx?collection=docs"), "email-blocks/tag-editor.mdx": () => import("../content/docs/email-blocks/tag-editor.mdx?collection=docs"), "email-blocks/template-chooser.mdx": () => import("../content/docs/email-blocks/template-chooser.mdx?collection=docs"), "email-blocks/trigger-picker.mdx": () => import("../content/docs/email-blocks/trigger-picker.mdx?collection=docs"), "getting-started/first-broadcast.mdx": () => import("../content/docs/getting-started/first-broadcast.mdx?collection=docs"), "getting-started/overview.mdx": () => import("../content/docs/getting-started/overview.mdx?collection=docs"), "email-marketing/broadcasts.mdx": () => import("../content/docs/email-marketing/broadcasts.mdx?collection=docs"), "email-marketing/contacts.mdx": () => import("../content/docs/email-marketing/contacts.mdx?collection=docs"), "email-marketing/sequences.mdx": () => import("../content/docs/email-marketing/sequences.mdx?collection=docs"), "email-marketing/settings.mdx": () => import("../content/docs/email-marketing/settings.mdx?collection=docs"), "email-marketing/templates.mdx": () => import("../content/docs/email-marketing/templates.mdx?collection=docs"), "workspace/account.mdx": () => import("../content/docs/workspace/account.mdx?collection=docs"), "workspace/media.mdx": () => import("../content/docs/workspace/media.mdx?collection=docs"), "workspace/organizations.mdx": () => import("../content/docs/workspace/organizations.mdx?collection=docs"), "workspace/settings.mdx": () => import("../content/docs/workspace/settings.mdx?collection=docs"), "workspace/teams.mdx": () => import("../content/docs/workspace/teams.mdx?collection=docs"), }), + docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "activity/suppressions.mdx": () => import("../content/docs/activity/suppressions.mdx?collection=docs"), "activity/transactional.mdx": () => import("../content/docs/activity/transactional.mdx?collection=docs"), "developers/api-keys.mdx": () => import("../content/docs/developers/api-keys.mdx?collection=docs"), "developers/authentication.mdx": () => import("../content/docs/developers/authentication.mdx?collection=docs"), "developers/errors.mdx": () => import("../content/docs/developers/errors.mdx?collection=docs"), "developers/mcp.mdx": () => import("../content/docs/developers/mcp.mdx?collection=docs"), "developers/overview.mdx": () => import("../content/docs/developers/overview.mdx?collection=docs"), "developers/provisioning.mdx": () => import("../content/docs/developers/provisioning.mdx?collection=docs"), "developers/transactional.mdx": () => import("../content/docs/developers/transactional.mdx?collection=docs"), "email-blocks/contact-filter-builder.mdx": () => import("../content/docs/email-blocks/contact-filter-builder.mdx?collection=docs"), "email-blocks/email-editor.mdx": () => import("../content/docs/email-blocks/email-editor.mdx?collection=docs"), "email-blocks/email-preview.mdx": () => import("../content/docs/email-blocks/email-preview.mdx?collection=docs"), "email-blocks/getting-started.mdx": () => import("../content/docs/email-blocks/getting-started.mdx?collection=docs"), "email-blocks/overview.mdx": () => import("../content/docs/email-blocks/overview.mdx?collection=docs"), "email-blocks/sequence-analytics.mdx": () => import("../content/docs/email-blocks/sequence-analytics.mdx?collection=docs"), "email-blocks/sequence-email-list.mdx": () => import("../content/docs/email-blocks/sequence-email-list.mdx?collection=docs"), "email-blocks/subscriber-list.mdx": () => import("../content/docs/email-blocks/subscriber-list.mdx?collection=docs"), "email-blocks/tag-editor.mdx": () => import("../content/docs/email-blocks/tag-editor.mdx?collection=docs"), "email-blocks/template-chooser.mdx": () => import("../content/docs/email-blocks/template-chooser.mdx?collection=docs"), "email-blocks/trigger-picker.mdx": () => import("../content/docs/email-blocks/trigger-picker.mdx?collection=docs"), "email-marketing/broadcasts.mdx": () => import("../content/docs/email-marketing/broadcasts.mdx?collection=docs"), "email-marketing/contacts.mdx": () => import("../content/docs/email-marketing/contacts.mdx?collection=docs"), "email-marketing/sequences.mdx": () => import("../content/docs/email-marketing/sequences.mdx?collection=docs"), "email-marketing/settings.mdx": () => import("../content/docs/email-marketing/settings.mdx?collection=docs"), "email-marketing/templates.mdx": () => import("../content/docs/email-marketing/templates.mdx?collection=docs"), "getting-started/first-broadcast.mdx": () => import("../content/docs/getting-started/first-broadcast.mdx?collection=docs"), "getting-started/overview.mdx": () => import("../content/docs/getting-started/overview.mdx?collection=docs"), "workspace/acceptable-use.mdx": () => import("../content/docs/workspace/acceptable-use.mdx?collection=docs"), "workspace/account.mdx": () => import("../content/docs/workspace/account.mdx?collection=docs"), "workspace/media.mdx": () => import("../content/docs/workspace/media.mdx?collection=docs"), "workspace/organizations.mdx": () => import("../content/docs/workspace/organizations.mdx?collection=docs"), "workspace/pricing.mdx": () => import("../content/docs/workspace/pricing.mdx?collection=docs"), "workspace/settings.mdx": () => import("../content/docs/workspace/settings.mdx?collection=docs"), "workspace/teams.mdx": () => import("../content/docs/workspace/teams.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/docs/content/docs/developers/errors.mdx b/apps/docs/content/docs/developers/errors.mdx index 344c87e..2d32c83 100644 --- a/apps/docs/content/docs/developers/errors.mdx +++ b/apps/docs/content/docs/developers/errors.mdx @@ -35,3 +35,25 @@ Missing and conversion errors include the affected paths: All template-purpose, structural, and missing-variable checks happen before an email is queued. A rejected request does not create a partial destination template or transactional delivery. + +## Plan and billing errors + +Plan limits and billing mutations use the same stable codes on REST and MCP: + +| Status | Error | Meaning | +| ------ | ---------------------------------- | ----------------------------------------------------------------------- | +| `403` | `plan_feature_unavailable` | The current plan does not include that capability. | +| `409` | `plan_limit_reached` | A team, contact, or send cap was reached. | +| `402` | `payment_required` | The organization cannot send until payment or activation. | +| `403` | `billing_owner_required` | Only an owner (or the billing manager) can perform that billing action. | +| `409` | `free_organization_already_owned` | The user already owns a Free organization. | +| `409` | `organization_name_already_exists` | The name is already used by an owned organization. | +| `409` | `billing_checkout_pending` | A checkout attempt is already open. | +| `409` | `billing_catalog_changed` | Prices changed; review the new catalog before retrying. | +| `503` | `billing_catalog_unavailable` | Checkout is frozen until the catalog is verified. | +| `409` | `active_subscription_exists` | Close is blocked while a subscription is live. | +| `401` | `recent_authentication_required` | Sign in again before a billing mutation. | +| `403` | `domain_verification_required` | The From domain must be verified after test volume. | +| `403` | `sending_paused` | Payment grace expired or a fair-use control paused sending. | + +Limit errors include `plan`, `usage`, `limit`, `requiredPlan`, and `upgradeUrl`. MCP tools return the same object in `structuredContent` instead of `internal_error`. diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index 2a42607..b197474 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -128,8 +128,11 @@ The ESP tools manage the team’s sending providers and default provider. Secret ### Teams and API keys - `list_teams`, `create_team`, `rename_team`, `delete_team` +- `get_plan_usage` returns the current team's parent-organization plan, usage, and limits. It never returns provider IDs or portal links. - `list_api_keys`, `create_api_key`, `delete_api_key` +Mutating tools such as `create_team`, `create_contact`, `update_contact`, `send_email`, and `start_sequence` return the same plan-gate error object as REST (`plan_limit_reached`, `plan_feature_unavailable`, `sending_paused`) in `structuredContent`. Checkout, portal, and invoice tools are not exposed through MCP. + The full API key secret is returned only by `create_api_key`. Store it immediately; it cannot be recovered from a later listing. ### Delivery feedback and suppressions diff --git a/apps/docs/content/docs/developers/provisioning.mdx b/apps/docs/content/docs/developers/provisioning.mdx index f35d47e..2e2222b 100644 --- a/apps/docs/content/docs/developers/provisioning.mdx +++ b/apps/docs/content/docs/developers/provisioning.mdx @@ -12,6 +12,13 @@ Provisioning is a backend-to-backend workflow. If you only need another team for people already using the SendLit dashboard, create it from the dashboard instead. +Provisioning and organization API keys are available on **Business** cloud +plans and on **OSS**. After a downgrade to Pro or Free, existing organization +keys remain stored for audit and revocation, but mutating provisioning +operations (`provision`, update, rotate keys, suspend, resume, archive) return +`plan_feature_unavailable`. Idempotent replay of an already-provisioned +`(externalId)` still succeeds. + ## How provisioning is scoped Every SendLit team belongs to exactly one organization. The organization API diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 21a3b32..d61b778 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -8,6 +8,8 @@ SendLit is an email marketing and automation service for managing contacts, reus ## What is documented here - **Getting started** covers the core SendLit concepts and the shortest path to a first send. +- **Workspace** explains organizations, pricing and billing, teams, and + account settings. - **Email marketing** explains contacts, templates, broadcasts, and sequences. - **Email blocks** documents the reusable React components exported by `@sendlit/email-blocks`. - **Developers** covers API authentication and integration guidance. diff --git a/apps/docs/content/docs/workspace/acceptable-use.mdx b/apps/docs/content/docs/workspace/acceptable-use.mdx new file mode 100644 index 0000000..78aa459 --- /dev/null +++ b/apps/docs/content/docs/workspace/acceptable-use.mdx @@ -0,0 +1,48 @@ +--- +title: Acceptable use and deliverability +description: Fair-use, reputation, and domain verification rules for SendLit cloud sending. +--- + +SendLit does not charge per email on Pro or Business. Cloud sending is still +subject to deliverability controls so one organization cannot damage shared +reputation. + +These rules apply to SendLit cloud. Self-hosted OSS installations run their own +infrastructure and are not frozen by SendLit cloud controls. + +## Reputation windows + +After a team has at least 500 accepted messages in a rolling seven-day window, +SendLit evaluates bounce and complaint rates from the outbound ledger. Retries +and duplicate provider events count once. + +| Signal | Action | +| -------------------------------- | -------------------------------------------------------------------------------------- | +| Bounce ≥ 2% or complaint ≥ 0.05% | Warn owners and admins | +| Bounce ≥ 5% or complaint ≥ 0.1% | Pause broadcasts and sequences; transactional mail continues at a degraded daily limit | +| Complaint ≥ 0.3% | Stop all new sends for that team | +| 10 complaints in seven days | Stop all new sends for that team, regardless of list size | + +`all_paused` does not auto-recover. A SendLit operator must review the list and +release the team, which starts at `warned`. + +## Paid marketing ramp + +From first paid activation, marketing volume ramps: + +- days 0–2: 200/day +- days 3–6: 1,000/day +- days 7–13: 10,000/day +- day 14 onward: no plan send cap, subject to fair use + +Transactional mail does not consume the marketing ramp. + +## Domain verification + +Cloud sending requires a verified organization owner email. After about 100 +accepted lifetime messages, the From domain must be verified with a DNS TXT +record at `_sendlit-verification.`. Plain SMTP can be used for tests +but cannot unlock paid volume without a healthy bounce/complaint feedback +connection. + +See [Pricing and billing](/workspace/pricing) for plan limits and checkout. diff --git a/apps/docs/content/docs/workspace/account.mdx b/apps/docs/content/docs/workspace/account.mdx index e75ea3b..e08b61d 100644 --- a/apps/docs/content/docs/workspace/account.mdx +++ b/apps/docs/content/docs/workspace/account.mdx @@ -1,8 +1,16 @@ --- -title: Account and billing -description: Review usage, quota, and account settings. +title: Account +description: Understand account access and organization-scoped billing. --- The dashboard home shows the current team’s delivery activity and allowance: sent, queued, failed, bounced, active sequences, scheduled broadcasts, and daily/monthly quota usage. -Open **Account** to review billing-related usage and quota details. Quotas are team-scoped, so switching teams changes the numbers shown in the dashboard. +Your account is your login and is never billed. Plans and subscriptions belong +to organizations, not accounts, logins, or teams. Open +[Organizations](/workspace/organizations) to view plan, payment status, limits, +and usage for a selected organization. See [Pricing and billing](/workspace/pricing) +for the complete plan rules. + +Quotas shown on the dashboard are team-scoped, so switching teams changes the +numbers shown there. Organization billing usage is shown on the organization's +**Plan** tab. diff --git a/apps/docs/content/docs/workspace/meta.json b/apps/docs/content/docs/workspace/meta.json index a9cfe46..18dffc6 100644 --- a/apps/docs/content/docs/workspace/meta.json +++ b/apps/docs/content/docs/workspace/meta.json @@ -1,5 +1,13 @@ { "title": "Workspace", "icon": "Settings", - "pages": ["organizations", "teams", "media", "settings", "account"] + "pages": [ + "organizations", + "pricing", + "acceptable-use", + "teams", + "media", + "settings", + "account" + ] } diff --git a/apps/docs/content/docs/workspace/organizations.mdx b/apps/docs/content/docs/workspace/organizations.mdx index 710ae36..ed52c11 100644 --- a/apps/docs/content/docs/workspace/organizations.mdx +++ b/apps/docs/content/docs/workspace/organizations.mdx @@ -36,11 +36,17 @@ This separation supports two common models: integrations. The Organizations page keeps the current organization in view, then splits -administration into tabs: **General** (organization name), **Delivery** +administration into tabs: **General** (organization name), **Plan** (plan, +payment status, limits, and usage), **Delivery** (shared mailboxes and default-delivery policy), **Teams** (grants and Enter team), **Members**, **Activity** (quota, transactional mail counts, and audit), and **Keys**. +Paid organizations can change plan from the **Plan** tab; +**Manage billing** there opens the hosted payment portal for cards, invoices, +cancellation, and payment recovery. See [Pricing and billing](/workspace/pricing) +for the plan comparison and downgrade behavior. + ## Organization membership and team access Organization membership and team membership are deliberately separate. diff --git a/apps/docs/content/docs/workspace/pricing.mdx b/apps/docs/content/docs/workspace/pricing.mdx new file mode 100644 index 0000000..dac02b0 --- /dev/null +++ b/apps/docs/content/docs/workspace/pricing.mdx @@ -0,0 +1,227 @@ +--- +title: Pricing and billing +description: Choose a SendLit plan, understand organization limits, and manage subscriptions. +--- + +SendLit is a ready-to-use email marketing platform for contacts, templates, +broadcasts, sequences, and transactional email. You keep your own email +service provider (such as Resend, Amazon SES, or Postmark), while SendLit +provides the workspace and delivery workflows. + +The cloud plans and the self-hosted distribution use the same product. Cloud +plans include managed hosting and operations. Self-hosting is the free OSS +distribution. + +## Plans at a glance + +| | OSS | Free | Pro | Business | +| -------------------------------------- | ------------ | ------------- | ------------------------- | ------------------------- | +| Where it runs | Your servers | SendLit cloud | SendLit cloud | SendLit cloud | +| Price | $0 | $0 | From the live catalog | From the live catalog | +| Teams | Unlimited | 1 | 5 | 25; higher by arrangement | +| Subscribed contacts | Unlimited | 1,000 | 10,000 per organization | No published cap\* | +| Sends through your ESP | Unlimited | 3,000/month | No usage charge; fair use | No usage charge; fair use | +| Shared organization mailbox | Yes | No | Yes | Yes | +| Provisioning API | Yes | No | No | Yes | +| Organization API keys | Yes | No | No | Yes | +| Embeddable React blocks | Yes | Yes | Yes | Yes | +| “Sent with SendLit” on marketing email | Off | On | Off | Off | +| Support | GitHub | GitHub | Email | Email | + +Yearly plans charge for ten months, so two months are free. The Pro monthly +plan includes a 14-day trial. Business has no trial. Checkout displays the +live catalog amounts and currency configured for the deployment; those amounts +are never hardcoded in the app. + +Free's 3,000-email allowance is monthly; there is no separate daily cap. + +\* Business has no published contact cap, but infrastructure and abuse +safeguards still apply. + +### What is included + +All plans include sequences, transactional email, the REST API, MCP, team ESP +connections, and the reusable React blocks. Blocks are part of SendLit; they +are not a separate paid kit. + +People are not seats. Members and logins are unlimited and are not billed. +SendLit also does not sell add-on packs of teams or contacts, and Pro and +Business are not charged per email. You pay your email provider separately. + +## How billing is scoped + +Billing belongs to an **organization**: + +- Your **account** is your login. It is never billed. +- An **organization** owns a plan and, on a paid plan, one subscription. +- A **team** owns contacts, templates, campaigns, sequences, and sending + settings. Teams are not billed separately. + +Two organizations with paid plans have two subscriptions. Being invited to +someone else's paid organization does not create a subscription for you. + +Open **Organizations** in the dashboard to select an organization, view its +plan and usage, upgrade, or create another organization. Billing is always +shown in the context of the organization being changed. + +## Cloud signup and upgrades + +Cloud signup creates one Free organization with one team. That team sends +through its own ESP. From the organization's **Plan** tab, choose **Upgrade** +to start Pro or Business checkout. A team-row **Upgrade parent organization** +action opens the same Plan tab; billing always belongs to the parent +organization. + +You may create additional organizations. You can own only one Free +organization; if you already own one, the next organization must be upgraded +as part of creation or the existing Free organization must be upgraded first. +Invitations do not count toward this rule. + +The organization and its team stay in place when you upgrade. No contacts, +sequences, templates, media, or sending history are migrated because nothing +needs to move. + +## Changing a paid plan + +SendLit owns the plan-change flow. From **Organizations → Plan**, choose +**Change plan** and select Pro or Business and the monthly or yearly interval. + +- Upgrades, including monthly to yearly, take effect immediately and use the + provider's supported proration behavior. +- Downgrades, including yearly to monthly, normally take effect on the next + billing date. Your current plan remains active until then. +- SendLit waits for a verified provider update before changing entitlements. + The organization may briefly show a pending plan change while that update is + being confirmed. + +**Manage billing** opens the payment provider's hosted portal for the selected +organization. Use it to update a card, view invoices, cancel a subscription, +or recover a failed payment. Plan and interval changes are initiated in +SendLit, not by editing the subscription in the provider portal. + +## Cancellation and expiry + +Cancellation normally takes effect at the end of the current paid period. +Until that verified paid-through time, the organization keeps its paid plan +and entitlements. Cancelling immediately (not at period end) removes paid +access as soon as SendLit receives the provider update, even if the previous +period end is still in the future. The Organizations **Plan** tab shows the +cancellation status when the provider update has been received. + +Checkout, plan changes, the billing portal, and closing an organization +require a recent sign-in. If the current session is too old, SendLit asks you +to sign in again before it can continue. + +If a checkout is interrupted, returning to the same organization and plan +resumes the pending checkout instead of starting a second subscription. + +At expiry, the organization becomes Free. Free limits are one active team, +1,000 subscribed contacts, and 3,000 sends per month. Expiry does **not** +delete data: + +- Organizations, teams, contacts, templates, sequences, broadcasts, media, + and logs remain available. +- Teams above the Free limit remain accessible, but no new team can be added. +- Contacts above the Free limit remain available for export, unsubscribe, and + deletion. New subscribed contacts and subscribed-contact imports are blocked + until usage is below the limit or the organization upgrades. +- Sequence and broadcast definitions, enrollments, send history, and logs are + retained. Queued marketing sends are rechecked against the Free plan; they + can continue only through a team ESP and within Free limits. Shared-mailbox + sends are blocked. +- Templates and media remain accessible; expiry does not run a cleanup or + delete them. +- Existing shared mailboxes and grants remain stored and readable, but cannot + be changed or used for new sends on Free. Configure a team ESP to continue + sending. +- Provisioning and organization-key mutations stop. Existing keys remain + available for audit and revocation; they do not bypass plan checks. + +An active sequence or broadcast is never allowed to bypass a plan limit after +expiry. Queued work is checked again at delivery time. Marketing delivery can +continue through a team ESP only when Free's limits and delivery checks allow +it. Transactional mail remains supported through a team ESP, subject to the +applicable Free sending limit; transactional-only recipients do not count as +subscribed contacts. + +Upgrading the same organization restores the paid capabilities without +recreating or migrating the data. + +## Contact and team limits + +Contact limits are per organization, not per team. For example, Pro's 10,000 +subscribed-contact allowance is shared by all five teams. The same email on two +teams counts twice. Password-reset and other transactional-only recipients do +not count as subscribed contacts. + +When an organization is over a new plan's limit, SendLit preserves the data +and blocks only operations that would make the overage worse. The dashboard and +API show current usage, the effective limit, and an upgrade path. + +## Team ESP and shared mailbox delivery + +Free uses a team-owned ESP configured in the team's settings. Pro, Business, +and OSS can additionally configure an organization-owned mailbox and grant it +to selected teams without exposing its credentials. + +The email provider's charges are separate from SendLit. SendLit does not add a +per-email charge to Pro or Business. Cloud volume sending is subject to +deliverability and fair-use controls, including bounce and complaint feedback +for volume providers. A team can be paused when its reputation crosses the +published safety thresholds. + +See [Acceptable use and deliverability](/workspace/acceptable-use) for the +full fair-use policy. For cloud paid plans, volume sending requires bounce and +complaint webhooks from a supported ESP. After enough volume (about 500 +accepted messages in seven days), SendLit applies these safeguards: + +| Signal | Action | +| -------------------------------- | ------------------------------------------------------------------------- | +| Bounce ≥ 2% or complaint ≥ 0.05% | Warn the team | +| Bounce ≥ 5% or complaint ≥ 0.1% | Stop broadcasts and sequences; transactional delivery may continue slowly | +| Complaint ≥ 0.3% | Stop all new sends for that team | + +Small samples are not judged by percentages alone; a short list with a few +bounces should not trip a threshold, while clear complaint abuse can still be +stopped. New paid organizations also ramp marketing volume gradually. Cloud +accounts need a verified email and a verified From domain before leaving test +volume. Self-hosted OSS installations run their own infrastructure and are not +frozen by SendLit cloud controls. + +## Self-hosting with OSS + +Docker Compose is the OSS distribution: all product features are enabled, no +payment provider is required, and there are no SendLit cloud plan limits or +marketing footer. CourseLit, FrontLit, and other products may run SendLit on +their own infrastructure under OSS. That is a self-hosted deployment, not a +cloud subscription. + +## Frequently asked questions + +### Do I pay per user, team, contact, or email? + +No. Paid plans are per organization. Members are free, teams and contacts are +bounded by the plan, and Pro and Business do not charge per email. You pay your +ESP directly for delivery. + +### Can I use one Pro subscription for two companies? + +No. Each organization has its own plan and subscription. Two paid +organizations mean two subscriptions. + +### Which plan is needed for an embedded product? + +Use OSS when you run SendLit yourself. Use Business on SendLit cloud when your +product needs the provisioning API and one SendLit team per tenant. Raise the +Business team cap by arrangement when 25 teams is not enough. + +### What happens if I need more contacts or teams? + +There are no add-on packs. Upgrade the organization. For more than 25 Business +teams, contact SendLit about a raised cap or self-host with OSS. + +### Who handles invoices and payment methods? + +The payment provider handles cards, taxes, invoices, and the hosted billing +portal. SendLit stores the subscription state and applies the plan rules. The +provider can change over time without changing these plan rules. diff --git a/apps/web/app/(dashboard)/account/page.tsx b/apps/web/app/(dashboard)/account/page.tsx index e6860b4..4b4fcc0 100644 --- a/apps/web/app/(dashboard)/account/page.tsx +++ b/apps/web/app/(dashboard)/account/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { CreditCard, Sparkles, UserRound } from "lucide-react"; +import { UserRound } from "lucide-react"; import { Loader } from "@codelitdev/design-system"; import { Loading } from "@/components/dashboard/loading"; import { PageHeader } from "@/components/dashboard/page-header"; @@ -25,7 +25,7 @@ import { TabsTrigger, } from "@/components/ui/codelit/tabs"; -const ACCOUNT_TABS = ["general", "billing"] as const; +const ACCOUNT_TABS = ["general"] as const; type AccountTab = (typeof ACCOUNT_TABS)[number]; interface Account { @@ -50,7 +50,12 @@ export default function AccountPage() { const [profileError, setProfileError] = useState(null); useEffect(() => { - if (tabParam !== "notifications") return; + if (tabParam !== "billing" && tabParam !== "notifications") return; + + if (tabParam === "billing") { + router.replace("/organizations?tab=plan", { scroll: false }); + return; + } const params = new URLSearchParams(searchParams.toString()); params.delete("tab"); @@ -126,7 +131,7 @@ export default function AccountPage() {
General - Billing @@ -250,31 +254,6 @@ export default function AccountPage() { - - - - - - - Billing - - - Manage your plan and payment details for - this account. - - - - -
-

Free plan

-

- Billing management will appear here when - subscriptions are available. -

-
-
-
-
diff --git a/apps/web/app/(dashboard)/organizations/page.test.tsx b/apps/web/app/(dashboard)/organizations/page.test.tsx index 89b33bf..c73018f 100644 --- a/apps/web/app/(dashboard)/organizations/page.test.tsx +++ b/apps/web/app/(dashboard)/organizations/page.test.tsx @@ -8,6 +8,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; const mocks = vi.hoisted(() => ({ @@ -17,11 +18,23 @@ const mocks = vi.hoisted(() => ({ listOrganizationKeys: vi.fn(), getOrganizationDeliveryPolicy: vi.fn(), listOrganizationMembers: vi.fn(), + listOrganizationSendingDomains: vi.fn(), + createOrganizationSendingDomain: vi.fn(), + verifyOrganizationSendingDomain: vi.fn(), + revokeOrganizationSendingDomain: vi.fn(), getOrganizationUsage: vi.fn(), getOrganizationMailActivity: vi.fn(), enterOrganizationTeam: vi.fn(), listOrganizationAuditEvents: vi.fn(), getOrganizationEspGrant: vi.fn(), + getOrganizationBilling: vi.fn(), + getBillingCatalog: vi.fn(), + createOrganizationBillingCheckout: vi.fn(), + createOrganizationBillingPlanChange: vi.fn(), + createPaidOrganizationBillingCheckout: vi.fn(), + createOrganizationBillingPortal: vi.fn(), + createOrganization: vi.fn(), + abandonPendingOrganization: vi.fn(), createOrganizationKey: vi.fn(), revokeOrganizationKey: vi.fn(), getOrganizationIdFromCookie: vi.fn(), @@ -30,6 +43,7 @@ const mocks = vi.hoisted(() => ({ setTeamIdCookie: vi.fn(), routerPush: vi.fn(), routerReplace: vi.fn(), + reloadPage: vi.fn(), })); vi.mock("@/lib/tokens", () => ({ @@ -53,14 +67,27 @@ vi.mock("@/lib/api", () => ({ listOrganizationKeys: mocks.listOrganizationKeys, getOrganizationDeliveryPolicy: mocks.getOrganizationDeliveryPolicy, listOrganizationMembers: mocks.listOrganizationMembers, + listOrganizationSendingDomains: mocks.listOrganizationSendingDomains, + createOrganizationSendingDomain: mocks.createOrganizationSendingDomain, + verifyOrganizationSendingDomain: mocks.verifyOrganizationSendingDomain, + revokeOrganizationSendingDomain: mocks.revokeOrganizationSendingDomain, getOrganizationUsage: mocks.getOrganizationUsage, getOrganizationMailActivity: mocks.getOrganizationMailActivity, enterOrganizationTeam: mocks.enterOrganizationTeam, listOrganizationAuditEvents: mocks.listOrganizationAuditEvents, getOrganizationEspGrant: mocks.getOrganizationEspGrant, + getOrganizationBilling: mocks.getOrganizationBilling, + getBillingCatalog: mocks.getBillingCatalog, + createOrganizationBillingCheckout: mocks.createOrganizationBillingCheckout, + createOrganizationBillingPlanChange: + mocks.createOrganizationBillingPlanChange, + createPaidOrganizationBillingCheckout: + mocks.createPaidOrganizationBillingCheckout, + createOrganizationBillingPortal: mocks.createOrganizationBillingPortal, + abandonPendingOrganization: mocks.abandonPendingOrganization, createOrganizationKey: mocks.createOrganizationKey, revokeOrganizationKey: mocks.revokeOrganizationKey, - createOrganization: vi.fn(), + createOrganization: mocks.createOrganization, updateOrganization: vi.fn(), addOrganizationMember: vi.fn(), updateOrganizationMember: vi.fn(), @@ -100,8 +127,13 @@ vi.mock("@/lib/api-client", () => ({ }, })); +vi.mock("@/lib/navigation", () => ({ + reloadPage: mocks.reloadPage, +})); + import OrganizationsPage from "./page"; import { BreadcrumbProvider } from "@/components/dashboard/breadcrumb-context"; +import { ApiError as ClientApiError } from "@/lib/api-client"; const organization = { organizationId: "org_1", @@ -133,7 +165,10 @@ const usageWindow = { beforeEach(() => { vi.clearAllMocks(); mocks.getOrganizationIdFromCookie.mockReturnValue("org_1"); - mocks.listOrganizations.mockResolvedValue({ items: [organization] }); + mocks.listOrganizations.mockResolvedValue({ + items: [organization], + ownsFreeOrganization: false, + }); mocks.listOrganizationTeams.mockResolvedValue({ items: [] }); mocks.listOrganizationEsps.mockResolvedValue({ items: [] }); mocks.listOrganizationKeys.mockResolvedValue({ items: [activeKey] }); @@ -149,6 +184,7 @@ beforeEach(() => { updatedAt: "2026-08-01T00:00:00.000Z", }); mocks.listOrganizationMembers.mockResolvedValue({ items: [] }); + mocks.listOrganizationSendingDomains.mockResolvedValue({ items: [] }); mocks.getOrganizationUsage.mockResolvedValue({ day: usageWindow, month: usageWindow, @@ -165,6 +201,38 @@ beforeEach(() => { }); mocks.listOrganizationAuditEvents.mockResolvedValue({ items: [] }); mocks.getOrganizationEspGrant.mockResolvedValue(null); + mocks.getOrganizationBilling.mockResolvedValue({ + plan: "oss", + billingInterval: null, + paymentStatus: "free", + trialEndsAt: null, + currentPeriodEndsAt: null, + cancelAtPeriodEnd: false, + graceEndsAt: null, + canManageBilling: true, + entitlements: { + teamsLimit: null, + subscribedContactsLimit: null, + monthlySendsLimit: null, + sharedOrganizationMailbox: true, + provisioning: true, + organizationApiKeys: true, + marketingBranding: false, + }, + usage: { + plan: "oss", + paymentStatus: "free", + teams: 0, + subscribedContacts: 0, + monthlySends: 0, + monthlySendsReserved: 0, + bucketStartsAt: "2026-08-01T00:00:00.000Z", + bucketEndsAt: "2026-09-01T00:00:00.000Z", + teamsLimit: null, + subscribedContactsLimit: null, + monthlySendsLimit: null, + }, + }); }); afterEach(() => cleanup()); @@ -243,6 +311,295 @@ describe("organization API keys", () => { }); }); +describe("billing presentation and entitlement hints", () => { + it("implicitly creates OSS organizations without a plan selector", async () => { + mocks.getBillingCatalog.mockResolvedValue({ + catalogRevision: null, + currency: null, + offers: [], + checkoutAvailable: false, + }); + mocks.createOrganization.mockResolvedValue(organization); + + renderPage(); + fireEvent.click( + await screen.findByRole("button", { name: "New organization" }), + ); + expect( + await screen.findByPlaceholderText("e.g. CourseLit"), + ).toBeTruthy(); + await waitFor(() => { + expect( + within(screen.getByRole("dialog")).queryByText("Plan", { + exact: true, + }), + ).toBeNull(); + }); + expect(screen.queryByText(/Paid checkout is not enabled/i)).toBeNull(); + + fireEvent.change(screen.getByPlaceholderText("e.g. CourseLit"), { + target: { value: "OSS Org" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Create organization" }), + ); + await waitFor(() => { + expect(mocks.createOrganization).toHaveBeenCalledWith("OSS Org"); + expect(mocks.reloadPage).toHaveBeenCalledTimes(1); + }); + }); + + it("shows the organization plan and usage summary", async () => { + renderPage(); + await openTab("Plan"); + expect(await screen.findByText("Plan and usage")).toBeTruthy(); + expect(screen.getByText("OSS")).toBeTruthy(); + expect(screen.getAllByText("0 · unlimited")).toHaveLength(3); + }); + + it("explains the end date and downgrade behavior for a cancelled plan", async () => { + mocks.getOrganizationBilling.mockResolvedValue({ + plan: "business", + billingInterval: "month", + paymentStatus: "cancelled", + trialEndsAt: null, + currentPeriodEndsAt: "2099-09-12T00:00:00.000Z", + cancelAtPeriodEnd: false, + graceEndsAt: null, + canManageBilling: true, + entitlements: { + teamsLimit: 25, + subscribedContactsLimit: null, + monthlySendsLimit: null, + sharedOrganizationMailbox: true, + provisioning: true, + organizationApiKeys: true, + marketingBranding: false, + }, + usage: { + plan: "business", + paymentStatus: "cancelled", + teams: 6, + subscribedContacts: 0, + monthlySends: 0, + monthlySendsReserved: 0, + bucketStartsAt: "2026-08-01T00:00:00.000Z", + bucketEndsAt: "2026-09-01T00:00:00.000Z", + teamsLimit: 25, + subscribedContactsLimit: null, + monthlySendsLimit: null, + }, + }); + + renderPage(); + await openTab("Plan"); + + expect( + screen.getByRole("button", { name: "Change plan" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Manage billing" }), + ).toBeTruthy(); + expect(await screen.findByText(/Cancellation scheduled/)).toBeTruthy(); + expect(screen.getByText(/remains active until/)).toBeTruthy(); + expect( + screen.getByText(/Organizations, teams, contacts, sequences/), + ).toBeTruthy(); + expect(screen.getByText(/The organization moves to Free/)).toBeTruthy(); + + await openTab("Teams"); + expect( + screen.queryByRole("button", { name: "Change plan" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Manage billing" }), + ).toBeNull(); + }); + + it("explains what remains after a paid plan expires", async () => { + mocks.getOrganizationBilling.mockResolvedValue({ + plan: "free", + billingInterval: null, + paymentStatus: "expired", + trialEndsAt: null, + currentPeriodEndsAt: "2026-08-20T00:00:00.000Z", + cancelAtPeriodEnd: true, + graceEndsAt: null, + canManageBilling: true, + entitlements: { + teamsLimit: 1, + subscribedContactsLimit: 1000, + monthlySendsLimit: 3000, + sharedOrganizationMailbox: false, + provisioning: false, + organizationApiKeys: false, + marketingBranding: true, + }, + usage: { + plan: "free", + paymentStatus: "expired", + teams: 1, + subscribedContacts: 0, + monthlySends: 0, + monthlySendsReserved: 0, + bucketStartsAt: "2026-08-01T00:00:00.000Z", + bucketEndsAt: "2026-09-01T00:00:00.000Z", + teamsLimit: 1, + subscribedContactsLimit: 1000, + monthlySendsLimit: 3000, + }, + }); + + renderPage(); + await openTab("Plan"); + + expect( + await screen.findByText(/organization is now on the Free plan/), + ).toBeTruthy(); + expect(screen.getByText(/paid plan ended on/)).toBeTruthy(); + expect(screen.getByText(/data are retained/)).toBeTruthy(); + }); + + it("explains when an owned organization already has that name", async () => { + mocks.getBillingCatalog.mockResolvedValue({ + catalogRevision: null, + currency: null, + offers: [], + checkoutAvailable: false, + }); + mocks.createOrganization.mockRejectedValue( + new ClientApiError(409, "organization_name_already_exists"), + ); + + renderPage(); + fireEvent.click( + await screen.findByRole("button", { name: "New organization" }), + ); + fireEvent.change(await screen.findByPlaceholderText("e.g. CourseLit"), { + target: { value: "Acme" }, + }); + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Create organization" }), + ).toBeTruthy(); + }); + fireEvent.click( + screen.getByRole("button", { name: "Create organization" }), + ); + + expect( + await screen.findByText( + /already own an organization with this name/i, + ), + ).toBeTruthy(); + expect(mocks.reloadPage).not.toHaveBeenCalled(); + }); + + it("explains the one-Free-organization rule", async () => { + mocks.getBillingCatalog.mockResolvedValue({ + catalogRevision: 1, + currency: "USD", + checkoutAvailable: true, + offers: [], + }); + mocks.createOrganization.mockRejectedValue( + new ClientApiError(409, "free_organization_already_owned"), + ); + + renderPage(); + fireEvent.click( + await screen.findByRole("button", { name: "New organization" }), + ); + fireEvent.change(await screen.findByPlaceholderText("e.g. CourseLit"), { + target: { value: "Another organization" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Create organization" }), + ); + + expect( + await screen.findByText( + /already own a Free organization\. Upgrade it first, or choose Pro or Business/i, + ), + ).toBeTruthy(); + expect( + screen.queryByText("free_organization_already_owned"), + ).toBeNull(); + }); + + it("disables Free-plan organization capabilities with upgrade copy", async () => { + mocks.getOrganizationBilling.mockResolvedValue({ + plan: "free", + billingInterval: null, + paymentStatus: "free", + trialEndsAt: null, + currentPeriodEndsAt: null, + cancelAtPeriodEnd: false, + graceEndsAt: null, + canManageBilling: true, + entitlements: { + teamsLimit: 1, + subscribedContactsLimit: 1000, + monthlySendsLimit: 3000, + sharedOrganizationMailbox: false, + provisioning: false, + organizationApiKeys: false, + marketingBranding: true, + }, + usage: { + plan: "free", + paymentStatus: "free", + teams: 1, + subscribedContacts: 1000, + monthlySends: 3000, + monthlySendsReserved: 0, + bucketStartsAt: "2026-08-01T00:00:00.000Z", + bucketEndsAt: "2026-09-01T00:00:00.000Z", + teamsLimit: 1, + subscribedContactsLimit: 1000, + monthlySendsLimit: 3000, + }, + }); + renderPage(); + await openTab("Teams"); + expect( + ( + screen.getByRole("button", { + name: "New team", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(screen.getByText(/reached its 1-team limit/i)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Upgrade" })).toBeNull(); + await openTab("Delivery"); + expect( + ( + screen.getByRole("button", { + name: "New shared ESP", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect( + screen.getByText( + /Shared mailboxes are available on Pro and Business/i, + ), + ).toBeTruthy(); + await openTab("Keys"); + expect( + ( + screen.getByRole("button", { + name: "New key", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect( + screen.getByText( + /Organization API keys are available on Business and OSS/i, + ), + ).toBeTruthy(); + }); +}); + const provisionedTeam = { teamId: "tm_school", name: "School One", diff --git a/apps/web/app/(dashboard)/organizations/page.tsx b/apps/web/app/(dashboard)/organizations/page.tsx index 85ca508..11881ae 100644 --- a/apps/web/app/(dashboard)/organizations/page.tsx +++ b/apps/web/app/(dashboard)/organizations/page.tsx @@ -12,9 +12,9 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Activity, Archive, + CalendarClock, CheckCircle2, Copy, - KeyRound, LogIn, Mail, MoreHorizontal, @@ -22,7 +22,7 @@ import { Plus, ShieldCheck, Send, - Server, + Sparkles, Trash2, Users, } from "lucide-react"; @@ -56,6 +56,7 @@ import { import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -95,6 +96,8 @@ import { TableRow, } from "@/components/ui/table"; import { ApiError } from "@/lib/api-client"; +import { reloadPage } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; import { getOrganizationIdFromCookie, notifyTeamsChanged, @@ -106,6 +109,9 @@ import { archiveOrganizationTeam, activateOrganizationEsp, createOrganization, + abandonPendingOrganization, + createPaidOrganizationBillingCheckout, + createOrganizationSendingDomain, createOrganizationEsp, createOrganizationKey, createOrganizationTeam, @@ -117,12 +123,23 @@ import { getOrganizationMailActivity, getOrganizationEspGrant, listOrganizationAuditEvents, + getBillingCatalog, + getOrganizationBilling, + createOrganizationBillingCheckout, + createOrganizationBillingPlanChange, + createOrganizationBillingPortal, + type BillingCatalog, + type OrganizationBilling, + type OrganizationPlanChange, listOrganizationEsps, listOrganizationKeys, listOrganizationMembers, + listOrganizationSendingDomains, listOrganizations, listOrganizationTeams, revokeOrganizationKey, + revokeOrganizationSendingDomain, + verifyOrganizationSendingDomain, renameOrganizationTeam, removeOrganizationMember, resumeOrganizationEsp, @@ -150,6 +167,7 @@ import { type OrganizationMember, type OrganizationTeam, type OrganizationUsage, + type SendingDomain, } from "@/lib/api"; const PROVIDERS: Array<{ value: EspProvider; label: string }> = [ @@ -173,12 +191,129 @@ const KEY_SCOPES: Array<{ value: OrganizationApiKeyScope; label: string }> = [ { value: "usage:read", label: "Read quota usage" }, ]; +const ORGANIZATION_ERROR_MESSAGES: Record = { + active_subscription_exists: + "This organization already has an active subscription.", + billing_catalog_changed: + "The available pricing changed. Close this dialog and try again to load the latest plans.", + billing_catalog_unavailable: + "Plans are temporarily unavailable. Please try again in a moment.", + billing_checkout_pending: + "Checkout is already in progress for this organization.", + billing_manager_required: + "Only the billing manager can change this organization’s billing.", + billing_owner_required: "Only the organization owner can manage billing.", + billing_plan_change_not_supported: + "That plan change is not available right now. Please try again later.", + billing_plan_change_pending: + "A plan change is already being processed for this organization.", + billing_plan_change_same_plan: + "This organization is already on that plan and billing interval.", + billing_provider_unavailable: + "Billing is temporarily unavailable. Please try again later.", + billing_human_session_required: + "For your security, sign in again before changing billing.", + billing_action_token_invalid: + "This secure billing request expired or was already used. Please try again.", + billing_action_token_required: + "This billing request could not be verified. Please try again.", + billing_security_unavailable: + "Secure billing verification is temporarily unavailable. Please try again shortly.", + csrf_origin_invalid: + "This billing request could not be verified. Refresh the page and try again.", + csrf_token_invalid: + "This billing request expired. Refresh the page and try again.", + recent_authentication_required: + "For your security, sign in again before changing billing.", + billing_subscription_not_changeable: + "This subscription cannot be changed right now. Please try again later.", + billing_subscription_required: + "An active subscription is required for this action.", + delivery_policy_not_found: + "Delivery settings are not available for this organization.", + delivery_source_in_use: + "This mailbox is still assigned to a team. Remove its assignments and try again.", + domain_exists: "That sending domain has already been added.", + domain_invalid: "Enter a valid domain, such as example.com.", + domain_not_found: "That sending domain could not be found.", + domain_public_suffix: + "Use a registrable domain, not a public suffix such as com or co.uk.", + domain_verification_pending: + "Domain verification is still pending. Add the DNS record and try again.", + esp_not_found: "That shared mailbox could not be found.", + feedback_not_configured: + "Delivery feedback is not configured for this mailbox yet.", + feedback_not_supported: + "This email provider does not support delivery feedback configuration.", + free_organization_already_owned: + "You already own a Free organization. Upgrade it first, or choose Pro or Business for this new organization.", + invalid_lifecycle_transition: + "That mailbox action is no longer available. Refresh the page and try again.", + key_not_found: "That organization key could not be found.", + last_organization_owner: + "An organization must have at least one owner. Add another owner before changing this role.", + member_exists: "That person is already a member of this organization.", + member_not_found: "That organization member could not be found.", + organization_esp_permission_required: + "You need organization administrator access to manage shared mailboxes.", + organization_membership_required: + "You must be a member of this organization to perform that action.", + organization_name_already_exists: + "You already own an organization with this name. Choose a different name.", + organization_name_required: "Enter an organization name.", + organization_not_found: + "This organization is no longer available. Refresh the page and try again.", + organization_owner_required: + "Only an organization owner can perform that action.", + organization_permission_required: + "You need organization administrator access to perform that action.", + organization_scope_required: + "This integration does not have permission to manage the organization.", + organization_esp_unavailable: + "The organization mailbox is unavailable. Check its configuration and try again.", + payment_required: + "An active subscription or payment method is required for this action.", + pending_organization_exists: + "You already have an organization awaiting payment. Finish that checkout before creating another.", + plan_feature_unavailable: + "This feature is not available on your current plan. Upgrade to continue.", + plan_limit_reached: + "This organization has reached its plan limit. Upgrade to continue.", + provider_capability_required: + "This email provider does not support that action.", + team_archived: "Archived teams cannot be edited.", + team_not_found: "That team could not be found.", + team_organization_mismatch: + "That team does not belong to this organization.", + user_auth_required: "Please sign in again to continue.", +}; + function errorMessage(error: unknown, fallback: string) { - return error instanceof ApiError ? error.message : fallback; + if (!(error instanceof ApiError)) return fallback; + const code = error.message.trim(); + const mapped = ORGANIZATION_ERROR_MESSAGES[code]; + if (mapped) return mapped; + + // Never expose an unrecognized machine-readable API code to end users. + // Preserve ordinary prose from validation/provider responses when it is + // already suitable for display. + if (/^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(code)) return fallback; + return code || fallback; +} + +function formatMinorAmount(amountMinor: number, currency: string): string { + const formatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + }); + const fractionDigits = + formatter.resolvedOptions().maximumFractionDigits ?? 2; + return formatter.format(amountMinor / 10 ** fractionDigits); } const ORGANIZATION_TABS = [ "general", + "plan", "delivery", "teams", "members", @@ -191,17 +326,187 @@ function isOrganizationTab(value: string | null): value is OrganizationTab { return ORGANIZATION_TABS.includes(value as OrganizationTab); } +const PLAN_LABELS: Record = { + oss: "OSS", + free: "Free", + pro: "Pro", + business: "Business", +}; + +const PAYMENT_STATUS_LABELS: Record< + OrganizationBilling["paymentStatus"], + string +> = { + free: "No subscription", + checkout_pending: "Checkout pending", + trialing: "Trial active", + active: "Active", + past_due: "Payment past due", + cancel_at_period_end: "Cancels at period end", + cancelled: "Cancelled", + expired: "Expired", +}; + +function usageLabel(value: number, limit: number | null): string { + const formattedValue = value.toLocaleString(); + return limit === null + ? `${formattedValue} · unlimited` + : `${formattedValue} / ${limit.toLocaleString()}`; +} + +function formatBillingDate(value: string | null): string | null { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +function hasFutureBillingDate(value: string | null): boolean { + if (!value) return false; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && timestamp > Date.now(); +} + +function usagePercentage(value: number, limit: number | null): number | null { + if (limit === null) return null; + return Math.min(100, Math.round((value / Math.max(1, limit)) * 100)); +} + +function paymentStatusVariant( + status: OrganizationBilling["paymentStatus"], +): "secondary" | "success" | "destructive" { + if (status === "active" || status === "trialing") return "success"; + if (status === "past_due") return "destructive"; + return "secondary"; +} + +function UsageMeter({ + icon, + label, + description, + value, + limit, +}: { + icon: React.ReactNode; + label: string; + description: string; + value: number; + limit: number | null; +}) { + const percentage = usagePercentage(value, limit); + const overLimit = limit !== null && value > limit; + const barWidth = percentage === null ? 0 : percentage; + const barColor = overLimit + ? "bg-destructive" + : percentage !== null && percentage >= 80 + ? "bg-amber-500" + : "bg-primary"; + + return ( +
+
+
+ {icon} +
+
+
+

{label}

+ {limit === null ? ( + Unlimited + ) : percentage !== null ? ( + = 80 + ? "text-amber-700 dark:text-amber-300" + : "text-muted-foreground", + )} + > + {overLimit + ? "Over limit" + : `${percentage}% used`} + + ) : null} +
+

+ {description} +

+
+
+

+ {usageLabel(value, limit)} +

+ {percentage !== null ? ( +
+
+
+ ) : ( +

+ No plan cap +

+ )} +
+ ); +} + +function OrganizationSectionHeader({ + title, + description, + action, +}: { + title: string; + description: React.ReactNode; + action?: React.ReactNode; +}) { + return ( + +
+ {title} +

+ {description} +

+
+ {action ? ( +
+ {action} +
+ ) : null} +
+ ); +} + export default function OrganizationsPage() { useSetBreadcrumb([{ label: "Organizations" }]); const router = useRouter(); const searchParams = useSearchParams(); const tabFromUrl = searchParams.get("tab"); + const confirmingCheckout = searchParams.get("billing") === "confirming"; const [selectedTab, setSelectedTab] = useState(() => isOrganizationTab(tabFromUrl) ? tabFromUrl : "general", ); const [organizations, setOrganizations] = useState( null, ); + const [ownsFreeOrganization, setOwnsFreeOrganization] = useState(false); const [selectedId, setSelectedId] = useState(() => getOrganizationIdFromCookie(), ); @@ -209,7 +514,14 @@ export default function OrganizationsPage() { const [esps, setEsps] = useState([]); const [keys, setKeys] = useState([]); const [members, setMembers] = useState([]); + const [sendingDomains, setSendingDomains] = useState([]); const [usage, setUsage] = useState(null); + const [billing, setBilling] = useState(null); + const [billingDialogOpen, setBillingDialogOpen] = useState(false); + const [planChangeDialogOpen, setPlanChangeDialogOpen] = useState(false); + const [checkoutConfirmation, setCheckoutConfirmation] = useState< + "idle" | "polling" | "confirmed" | "timed_out" + >("idle"); const [mailActivity, setMailActivity] = useState(null); const [mailRangeDays, setMailRangeDays] = @@ -244,6 +556,7 @@ export default function OrganizationsPage() { try { const result = await listOrganizations(); setOrganizations(result.items); + setOwnsFreeOrganization(Boolean(result.ownsFreeOrganization)); const nextId = preferredId && result.items.some((item) => item.organizationId === preferredId) @@ -274,35 +587,60 @@ export default function OrganizationsPage() { setHasManagementAccess(null); } setError(null); + try { + const billingResult = await getOrganizationBilling(organizationId); + setBilling(billingResult); + } catch (err) { + if (!( + err instanceof ApiError && + (err.message === "organization_permission_required" || + err.message === "organization_owner_required") + )) { + setError(errorMessage(err, "Failed to load billing")); + } + } try { const [ teamResult, espResult, - keyResult, policyResult, memberResult, + domainResult, usageResult, mailActivityResult, auditResult, ] = await Promise.all([ listOrganizationTeams(organizationId), listOrganizationEsps(organizationId), - listOrganizationKeys(organizationId), getOrganizationDeliveryPolicy(organizationId), listOrganizationMembers(organizationId), + listOrganizationSendingDomains(organizationId), getOrganizationUsage(organizationId), getOrganizationMailActivity(organizationId, mailRangeDays), listOrganizationAuditEvents(organizationId), ]); setTeams(teamResult.items); setEsps(espResult.items); - setKeys(keyResult.items); setPolicy(policyResult); setMembers(memberResult.items); + setSendingDomains(domainResult.items); setUsage(usageResult); setMailActivity(mailActivityResult); setAuditEvents(auditResult.items); setHasManagementAccess(true); + try { + const keyResult = await listOrganizationKeys(organizationId); + setKeys(keyResult.items); + } catch (err) { + if ( + err instanceof ApiError && + err.message === "organization_owner_required" + ) { + setKeys([]); + } else { + throw err; + } + } const pairs = await Promise.all( teamResult.items.map( async (team) => @@ -319,7 +657,8 @@ export default function OrganizationsPage() { } catch (err) { if ( err instanceof ApiError && - err.message === "organization_permission_required" + (err.message === "organization_permission_required" || + err.message === "organization_owner_required") ) { setHasManagementAccess(false); setError(null); @@ -332,7 +671,7 @@ export default function OrganizationsPage() { } useEffect(() => { - void loadOrganizations(); + void loadOrganizations(searchParams.get("organization") ?? undefined); // Load once on mount; subsequent changes are driven by the selector. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -343,9 +682,13 @@ export default function OrganizationsPage() { setEsps([]); setKeys([]); setMembers([]); + setSendingDomains([]); setUsage(null); setMailActivity(null); setAuditEvents([]); + setBilling(null); + setBillingDialogOpen(false); + setPlanChangeDialogOpen(false); setPolicy(null); setGrants({}); return; @@ -354,6 +697,52 @@ export default function OrganizationsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId]); + useEffect(() => { + if (!confirmingCheckout || !selectedId) return; + const organizationId = selectedId; + let cancelled = false; + const startedAt = Date.now(); + setCheckoutConfirmation("polling"); + + async function poll() { + try { + const nextBilling = + await getOrganizationBilling(organizationId); + if (cancelled) return; + setBilling(nextBilling); + const activated = + nextBilling.plan === "pro" || + nextBilling.plan === "business"; + if (activated) { + setCheckoutConfirmation("confirmed"); + const params = new URLSearchParams(searchParams.toString()); + params.delete("billing"); + params.delete("organization"); + router.replace(`/organizations?${params.toString()}`, { + scroll: false, + }); + return; + } + } catch { + // Keep polling. The provider webhook may still be in flight. + } + if (cancelled) return; + if (Date.now() - startedAt >= 90_000) { + setCheckoutConfirmation("timed_out"); + return; + } + window.setTimeout(() => void poll(), 2_000); + } + + void poll(); + return () => { + cancelled = true; + }; + // searchParams is intentionally omitted: its value is captured for + // the one return-flow poll and router.replace removes billing. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [confirmingCheckout, selectedId, router]); + useEffect(() => { if (selectedOrganization) setOrganizationName(selectedOrganization.name); @@ -435,17 +824,18 @@ export default function OrganizationsPage() { } > {organization.name} + {organization.status === + "pending_payment" + ? " (pending payment)" + : ""} ))} ) : null} - void loadOrganizations( - organization.organizationId, - ) - } + hasFreeOrganization={ownsFreeOrganization} + onCreated={reloadPage} />
} @@ -453,6 +843,27 @@ export default function OrganizationsPage() { {error && {error}} + {checkoutConfirmation === "polling" && ( + + Confirming your subscription. We’ll update this + organization as soon as the payment provider confirms + it. + + )} + {checkoutConfirmation === "confirmed" && ( + + Subscription confirmed. Your organization’s paid + features are now available. + + )} + {checkoutConfirmation === "timed_out" && ( + + Payment is still being confirmed. Refresh this page in a + moment; your organization will update automatically when + the provider webhook arrives. + + )} + {organizations.length === 0 ? ( @@ -462,213 +873,354 @@ export default function OrganizationsPage() { ) : ( <> + {selectedId && + billing && + hasManagementAccess === false ? ( +
+ setBillingDialogOpen(true)} + onChangePlan={() => + setPlanChangeDialogOpen(true) + } + /> +
+ ) : null} + + {selectedId && + selectedOrganization?.status === "pending_payment" ? ( + +
+

+ This organization is awaiting payment + and cannot send until checkout is + confirmed. +

+
+ + +
+
+
+ ) : null} + {selectedId && hasManagementAccess === false && ( You are a member of this organization, but only organization owners and administrators can manage shared mailboxes, teams, and - members. Team content still requires an - explicit team membership. + members. Plan and usage are shown above. + Team content still requires an explicit team + membership. )} {selectedId && hasManagementAccess === true && ( - - - selectTab("general")} - > - General - - selectTab("delivery")} - > - Delivery - - selectTab("teams")} - > - Teams - - selectTab("members")} - > - Members - - selectTab("activity")} - > - Activity - - selectTab("keys")} - > - Keys - - - {selectedTab === "general" && ( - - - - - Organization - - - -
- - - setOrganizationName( - event.target - .value, - ) + <> + + + selectTab("general")} + > + General + + selectTab("plan")} + > + Plan + + + selectTab("delivery") + } + > + Delivery + + selectTab("teams")} + > + Teams + + selectTab("members")} + > + Members + + + selectTab("activity") + } + > + Activity + + selectTab("keys")} + > + Keys + + + {selectedTab === "general" && ( + + + + +
+ + + setOrganizationName( + event.target + .value, + ) + } + /> +
+
+ +
-
- - + +
+
+ )} + {selectedTab === "plan" && ( + + {billing ? ( + + setBillingDialogOpen( + true, + ) } - disabled={ - savingName || - !organizationName.trim() || - organizationName.trim() === - selectedOrganization?.name + onChangePlan={() => + setPlanChangeDialogOpen( + true, + ) } - > - {savingName - ? "Saving…" - : "Save"} - - - - - )} - {selectedTab === "delivery" && ( - - - setEsps((current) => - current.map((esp) => - esp.espId === - updated.espId - ? updated - : esp, - ), - ) - } - onEspDeleted={(espId) => - setEsps((current) => - current.filter( - (esp) => - esp.espId !== espId, - ), - ) - } - /> - { - await loadDetails(selectedId); - }} - /> - - )} - {selectedTab === "teams" && ( - - - - )} - {selectedTab === "members" && ( - - - - )} - {selectedTab === "activity" && ( - - { - setMailRangeDays(days); - if (!selectedId) return; - try { - setMailActivity( - await getOrganizationMailActivity( - selectedId, - days, + /> + ) : ( + + )} + + )} + {selectedTab === "delivery" && ( + + + setEsps((current) => + current.map((esp) => + esp.espId === + updated.espId + ? updated + : esp, ), - ); - } catch (err) { - setError( - errorMessage( - err, - "Failed to load mail activity", + ) + } + onEspDeleted={(espId) => + setEsps((current) => + current.filter( + (esp) => + esp.espId !== + espId, ), + ) + } + /> + { + await loadDetails( + selectedId, ); + }} + /> + { + setSendingDomains( + ( + await listOrganizationSendingDomains( + selectedId, + ) + ).items, + ); + }} + /> + + )} + {selectedTab === "teams" && ( + + + selectTab("plan") } - }} - events={auditEvents} - loading={loadingDetails} - /> - - )} - {selectedTab === "keys" && ( - - + + )} + {selectedTab === "members" && ( + + + + )} + {selectedTab === "activity" && ( + + { + setMailRangeDays(days); + if (!selectedId) return; + try { + setMailActivity( + await getOrganizationMailActivity( + selectedId, + days, + ), + ); + } catch (err) { + setError( + errorMessage( + err, + "Failed to load mail activity", + ), + ); + } + }} + events={auditEvents} + loading={loadingDetails} + /> + + )} + {selectedTab === "keys" && ( + + + + )} +
+ {billing ? ( + <> + - - )} - + {billing.plan !== "free" && + billing.plan !== "oss" ? ( + + ) : null} + + ) : null} + )} )} @@ -677,25 +1229,406 @@ export default function OrganizationsPage() { ); } +function OrganizationPlanSummary({ + organizationId, + billing, + onUpgrade, + onChangePlan, +}: { + organizationId: string; + billing: OrganizationBilling; + onUpgrade: () => void; + onChangePlan: () => void; +}) { + const { usage, entitlements } = billing; + const periodEnd = formatBillingDate(billing.currentPeriodEndsAt); + const trialEnd = formatBillingDate(billing.trialEndsAt); + // Providers may represent a period-end cancellation as either an explicit + // cancel-at-period-end flag or a cancelled subscription that remains paid + // through the current period. Treat both forms consistently in the UI. + const cancellationRequested = + billing.cancelAtPeriodEnd || + billing.paymentStatus === "cancel_at_period_end" || + billing.paymentStatus === "cancelled"; + const cancellationPending = + cancellationRequested && + (billing.plan === "pro" || billing.plan === "business") && + hasFutureBillingDate(billing.currentPeriodEndsAt); + const subscriptionExpired = + billing.paymentStatus === "expired" || + (billing.plan === "free" && billing.paymentStatus === "cancelled"); + const periodLabel = subscriptionExpired + ? periodEnd + ? `Ended ${periodEnd}` + : null + : cancellationPending + ? periodEnd + ? `Access until ${periodEnd}` + : null + : trialEnd + ? `Trial ends ${trialEnd}` + : periodEnd + ? `Renews ${periodEnd}` + : null; + + async function openBillingPortal() { + try { + const result = + await createOrganizationBillingPortal(organizationId); + window.location.assign(result.portalUrl); + } catch (error) { + toast.error(errorMessage(error, "Unable to open billing portal")); + } + } + + return ( + + +
+
+ Plan and usage +

+ Organization-level limits shared across your teams. + Members and logins are never billed as seats. +

+
+
+ {billing.canManageBilling && billing.plan === "free" ? ( + + ) : null} + {billing.canManageBilling && + billing.plan !== "free" && + billing.plan !== "oss" ? ( + <> + + + + ) : null} + + {PLAN_LABELS[billing.plan]} + +
+
+
+ + {PLAN_LABELS[billing.plan]} plan + + {billing.billingInterval ? ( + + ·{" "} + {billing.billingInterval === "month" + ? "Monthly" + : "Yearly"}{" "} + billing + + ) : null} + + {PAYMENT_STATUS_LABELS[billing.paymentStatus]} + + {periodLabel ? ( + + · {periodLabel} + + ) : null} +
+
+ +
+
+

Usage

+

+ Current usage for this organization +

+
+ {formatBillingDate(usage.bucketEndsAt) ? ( + + Resets {formatBillingDate(usage.bucketEndsAt)} + + ) : null} +
+ {(entitlements.subscribedContactsLimit !== null && + usage.subscribedContacts > + entitlements.subscribedContactsLimit) || + (entitlements.monthlySendsLimit !== null && + usage.monthlySends > entitlements.monthlySendsLimit) ? ( + + This organization is over its plan limit + {entitlements.subscribedContactsLimit !== null && + usage.subscribedContacts > + entitlements.subscribedContactsLimit + ? ` (${usage.subscribedContacts} of ${entitlements.subscribedContactsLimit} subscribed contacts)` + : ""} + {entitlements.monthlySendsLimit !== null && + usage.monthlySends > entitlements.monthlySendsLimit + ? ` (${usage.monthlySends} of ${entitlements.monthlySendsLimit} monthly sends)` + : ""} + . Export, unsubscribe, or delete contacts to get under + the cap, then upgrade if you need more capacity. + + ) : null} +
+ } + label="Teams" + description="Active teams" + value={usage.teams} + limit={entitlements.teamsLimit} + /> + } + label="Subscribed contacts" + description="Across all teams" + value={usage.subscribedContacts} + limit={entitlements.subscribedContactsLimit} + /> + } + label="Monthly sends" + description="Resets each month" + value={usage.monthlySends} + limit={entitlements.monthlySendsLimit} + /> +
+ {billing.paymentStatus === "past_due" ? ( + +
+

+ Payment is past due + {formatBillingDate(billing.graceEndsAt) + ? `. Paid sending remains available until ${formatBillingDate(billing.graceEndsAt)}` + : ""} + . Update the card on file to keep paid sending + enabled. +

+ {billing.canManageBilling ? ( + + ) : null} +
+
+ ) : null} + {cancellationPending ? ( + +
+
+ +
+
+
+

+ Cancellation scheduled +

+ {periodEnd ? ( + + Ends {periodEnd} + + ) : null} +
+

+ Your {PLAN_LABELS[billing.plan]} plan + remains active + {periodEnd + ? ` until ${periodEnd}` + : " through the current billing period"} + . Paid features are available until then. +

+
+
+

+ After expiry +

+

+ The organization moves to Free: 1 + active team, 1,000 subscribed + contacts, and 3,000 sends per month. + New additions or sends over those + limits are blocked. +

+
+
+

+ What stays +

+

+ Organizations, teams, contacts, + sequences, broadcasts, templates, + media, logs, and existing keys + remain. Shared mailboxes and grants + stay readable but cannot be used for + new sends. +

+
+
+

+ Provisioning and organization-key mutations + stop after expiry. Upgrade this organization + again to restore paid capabilities without + migrating data. +

+
+
+
+ ) : null} + {subscriptionExpired ? ( + +
+
+ +
+
+

+ This organization is now on the Free plan. + {periodEnd + ? ` The paid plan ended on ${periodEnd}.` + : null} +

+

+ Your teams, contacts, sequences, broadcasts, + templates, media, logs, and other data are + retained. Free limits apply; shared + mailboxes and grants remain readable but + cannot be used for new sends. Upgrade again + to restore paid capabilities. +

+
+
+
+ ) : null} + {!cancellationPending && + cancellationRequested && + (billing.plan === "pro" || billing.plan === "business") ? ( + + This subscription is cancelled. Your paid features + remain available through the current billing period. The + provider has not supplied an exact end date yet. + + ) : null} +
+
+ ); +} + function CreateOrganizationDialog({ + hasFreeOrganization, onCreated, }: { - onCreated: (organization: Organization) => void; + hasFreeOrganization: boolean; + onCreated: () => void; }) { const [open, setOpen] = useState(false); const [name, setName] = useState(""); + const [teamName, setTeamName] = useState(""); + const [plan, setPlan] = useState<"oss" | "free" | "pro" | "business">( + "free", + ); + const [interval, setInterval] = useState<"month" | "year">("month"); + const [catalog, setCatalog] = useState(null); + const [catalogUnavailable, setCatalogUnavailable] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const isOssDeployment = + catalog?.catalogRevision === null && !catalog.checkoutAvailable; + const showPlanSelector = + catalogUnavailable || (catalog !== null && !isOssDeployment); + + useEffect(() => { + if (!open) return; + setError(null); + setPlan(hasFreeOrganization ? "pro" : "free"); + setInterval("month"); + setTeamName(""); + setCatalog(null); + setCatalogUnavailable(false); + void (async () => { + try { + const nextCatalog = await getBillingCatalog(); + setCatalog(nextCatalog); + if ( + nextCatalog.catalogRevision === null && + !nextCatalog.checkoutAvailable + ) { + setPlan("oss"); + } else { + setPlan(hasFreeOrganization ? "pro" : "free"); + } + } catch { + setCatalog(null); + setCatalogUnavailable(true); + } + })(); + }, [hasFreeOrganization, open]); + + const offer = + plan === "free" || plan === "oss" + ? null + : catalog?.offers.find( + (item) => item.plan === plan && item.interval === interval, + ); + async function submit() { if (!name.trim()) return; + if ( + plan !== "free" && + plan !== "oss" && + (!catalog?.catalogRevision || !offer) + ) { + setError( + "Paid plans are temporarily unavailable. Please try again.", + ); + return; + } setSaving(true); setError(null); try { - const organization = await createOrganization(name.trim()); - setOpen(false); - setName(""); - onCreated(organization); + if (plan === "free" || plan === "oss") { + await createOrganization(name.trim()); + setOpen(false); + setName(""); + onCreated(); + } else { + const result = await createPaidOrganizationBillingCheckout({ + organizationName: name.trim(), + teamName: teamName.trim() || `${name.trim()} Team`, + plan, + interval, + catalogRevision: catalog!.catalogRevision!, + }); + // Activation is webhook-driven; the hosted provider page is + // the only place where payment details are entered. + window.location.assign(result.checkoutUrl); + } } catch (err) { setError(errorMessage(err, "Failed to create organization")); } finally { @@ -703,6 +1636,16 @@ function CreateOrganizationDialog({ } } + const submitLabel = saving + ? plan === "free" + ? "Creating…" + : "Opening checkout…" + : !catalog && !catalogUnavailable + ? "Loading…" + : plan === "free" || plan === "oss" + ? "Create organization" + : "Continue to checkout"; + return ( @@ -725,12 +1668,134 @@ function CreateOrganizationDialog({ placeholder="e.g. CourseLit" /> + {showPlanSelector ? ( +
+ + + {hasFreeOrganization ? ( +

+ Your existing Free organization means this new + organization must use a paid plan. +

+ ) : null} +
+ ) : null} + {catalogUnavailable || + (catalog && + !catalog.checkoutAvailable && + catalog.catalogRevision !== null) ? ( +

+ Paid checkout is not enabled for this deployment. +

+ ) : null} + {plan !== "free" && plan !== "oss" ? ( + <> +
+ + + setTeamName(event.target.value) + } + placeholder="e.g. Main team" + /> +
+
+ + +
+ {offer ? ( +

+ {formatMinorAmount( + offer.amountMinor, + offer.currency, + )}{" "} + / {interval} + {offer.trialDays + ? ` · ${offer.trialDays}-day trial` + : ""} +

+ ) : ( +

+ Loading the current provider-configured price… +

+ )} + + ) : null} @@ -742,6 +1807,7 @@ function SharedEspsSection({ organizationId, esps, loading, + billing, onChanged, onEspUpdated, onEspDeleted, @@ -749,6 +1815,7 @@ function SharedEspsSection({ organizationId: string; esps: EspConfig[]; loading: boolean; + billing: OrganizationBilling | null; onChanged: () => Promise; onEspUpdated: (esp: EspConfig) => void; onEspDeleted: (espId: string) => void; @@ -761,6 +1828,8 @@ function SharedEspsSection({ const [transitioningId, setTransitioningId] = useState(null); const [retiringEsp, setRetiringEsp] = useState(null); const [deletingEsp, setDeletingEsp] = useState(null); + const sharedMailboxEnabled = + billing === null || billing.entitlements.sharedOrganizationMailbox; async function test(espId: string) { setTestingId(espId); @@ -856,25 +1925,33 @@ function SharedEspsSection({ return ( - -
- - - Shared mailboxes - -

+ Shared ESPs are organization-owned. Configure credentials once, then grant the mailbox to selected teams. A team receives only a delivery option after an explicit grant; credentials never enter team APIs. -

-
- -
+ + } + action={ + + } + /> + {!sharedMailboxEnabled ? ( + + Shared mailboxes are available on Pro and Business. + Upgrade this organization to configure one. + + ) : null} {loading ? ( ) : esps.length === 0 ? ( @@ -1496,14 +2573,17 @@ function DeliveryPolicySection({ const activeEsps = esps.filter((esp) => esp.status === "active"); return ( - - Default delivery for new teams -

- This policy powers CourseLit-style provisioning: each new - team can automatically receive this shared ESP as its - default delivery source and inherit the quota limits below. -

-
+ + This policy powers CourseLit-style provisioning: each + new team can automatically receive this shared ESP as + its default delivery source and inherit the quota limits + below. + + } + /> {loading ? ( @@ -1646,12 +2726,246 @@ function DeliveryPolicySection({ ); } +function SendingDomainsSection({ + organizationId, + domains, + onChanged, +}: { + organizationId: string; + domains: SendingDomain[]; + onChanged: () => Promise; +}) { + const [open, setOpen] = useState(false); + const [domain, setDomain] = useState(""); + const [challenge, setChallenge] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [revoking, setRevoking] = useState(null); + + async function addDomain() { + if (!domain.trim()) return; + setSaving(true); + setError(null); + try { + const created = await createOrganizationSendingDomain( + organizationId, + domain.trim(), + ); + setChallenge(created); + setDomain(""); + setOpen(false); + await onChanged(); + } catch (err) { + setError(errorMessage(err, "Failed to add sending domain")); + } finally { + setSaving(false); + } + } + + async function verify(domainId: string) { + setSaving(true); + setError(null); + try { + await verifyOrganizationSendingDomain(organizationId, domainId); + await onChanged(); + } catch (err) { + setError(errorMessage(err, "Domain is not verified yet")); + await onChanged(); + } finally { + setSaving(false); + } + } + + async function revoke() { + if (!revoking) return; + setSaving(true); + setError(null); + try { + await revokeOrganizationSendingDomain( + organizationId, + revoking.domainId, + ); + setRevoking(null); + await onChanged(); + } catch (err) { + setError(errorMessage(err, "Failed to revoke sending domain")); + } finally { + setSaving(false); + } + } + + return ( + <> + + setOpen(true)}> + + Add domain + + } + /> + + {error ? {error} : null} + {challenge ? ( +
+

+ Add this DNS TXT record +

+

+ Publish it, then choose Verify. The token is + shown only once. +

+
+
+

+ Name +

+ + {challenge.challengeRecordName} + +
+
+

+ Value +

+ + {challenge.challengeRecordValue} + +
+
+
+ ) : null} + {domains.length === 0 ? ( +

+ No sending domains configured. +

+ ) : ( +
+ {domains.map((item) => ( +
+
+

+ {item.domain} +

+

+ {item.lastCheckedAt + ? `Checked ${new Date(item.lastCheckedAt).toLocaleDateString()}` + : "Not checked yet"} +

+
+ + {item.status} + + {item.status !== "revoked" ? ( +
+ + +
+ ) : null} +
+ ))} +
+ )} +
+
+ + + + Add sending domain + +
+ + setDomain(event.target.value)} + placeholder="example.com" + /> +
+ + + +
+
+ !value && setRevoking(null)} + > + + + + Revoke sending domain? + + + New sends from {revoking?.domain} will require + another verified domain. + + + + + Cancel + + { + event.preventDefault(); + void revoke(); + }} + > + Revoke domain + + + + + + ); +} + function TeamsAndGrantsSection({ organizationId, teams, esps, grants, loading, + billing, + onUpgradeParent, onChanged, }: { organizationId: string; @@ -1659,29 +2973,66 @@ function TeamsAndGrantsSection({ esps: EspConfig[]; grants: Record; loading: boolean; + billing: OrganizationBilling | null; + onUpgradeParent: () => void; onChanged: () => Promise; }) { const [newTeamOpen, setNewTeamOpen] = useState(false); const activeTeams = teams.filter((team) => team.status !== "archived"); + const teamLimitReached = Boolean( + billing?.entitlements.teamsLimit !== null && + billing?.entitlements.teamsLimit !== undefined && + billing.usage.teams >= billing.entitlements.teamsLimit, + ); + const sharedMailboxEnabled = + billing === null || billing.entitlements.sharedOrganizationMailbox; return ( - -
- - - Teams and mailbox sharing - -

- Each team can receive one active shared ESP grant. Its - members see a sending option, never mailbox credentials. -

-
- -
+ + + + } + /> + {billing?.pendingPlanChange ? ( + + Plan change to{" "} + {PLAN_LABELS[billing.pendingPlanChange.targetPlan]} ( + {billing.pendingPlanChange.targetInterval === "month" + ? "monthly" + : "yearly"} + ) is{" "} + {billing.pendingPlanChange.effectiveAt === "immediately" + ? "being confirmed" + : "scheduled for the next billing date"} + . + + ) : null} + {teamLimitReached ? ( + + This organization has reached its{" "} + {billing?.entitlements.teamsLimit}-team limit. Upgrade + to add another team. + + ) : null} + {billing && + billing.entitlements.teamsLimit !== null && + !teamLimitReached ? ( +

+ {billing.usage.teams} of{" "} + {billing.entitlements.teamsLimit} teams used. +

+ ) : null} {loading ? ( ) : activeTeams.length === 0 ? ( @@ -1697,6 +3048,8 @@ function TeamsAndGrantsSection({ team={team} esps={esps} grant={grants[team.teamId] ?? null} + sharedMailboxEnabled={sharedMailboxEnabled} + onUpgradeParent={onUpgradeParent} onChanged={onChanged} /> ))} @@ -1772,17 +3125,488 @@ function CreateOrganizationTeamDialog({ ); } +function OrganizationBillingDialog({ + organizationId, + billing, + open, + onOpenChange, +}: { + organizationId: string; + billing: OrganizationBilling | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [catalog, setCatalog] = useState(null); + const [plan, setPlan] = useState<"pro" | "business">("pro"); + const [interval, setInterval] = useState<"month" | "year">("month"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setError(null); + setCatalog(null); + void (async () => { + try { + setCatalog(await getBillingCatalog()); + } catch (err) { + setError(errorMessage(err, "Unable to load billing plans")); + } + })(); + }, [open]); + + const offer = catalog?.offers.find( + (item) => item.plan === plan && item.interval === interval, + ); + + async function checkout() { + if (!catalog?.catalogRevision || !offer) return; + setLoading(true); + setError(null); + try { + const result = await createOrganizationBillingCheckout( + organizationId, + { plan, interval, catalogRevision: catalog.catalogRevision }, + ); + window.location.assign(result.checkoutUrl); + } catch (err) { + setError(errorMessage(err, "Unable to start checkout")); + } finally { + setLoading(false); + } + } + + return ( + + +
+ +
+
+ +
+
+ + Upgrade organization + + + Unlock more room to grow while keeping + billing neatly scoped to this organization. + +
+
+
+
+ +
+ {error ? {error} : null} + {!catalog ? ( + error ? ( +
+ Close this dialog and try again once billing + plans are available. +
+ ) : ( + + ) + ) : ( + <> +
+
+ + +
+
+ + +
+
+ + {offer ? ( +
+
+
+
+
+

+ {plan === "pro" + ? "Pro workspace" + : "Business workspace"} +

+ {plan === "pro" ? ( + + Most popular + + ) : null} +
+

+ {plan === "pro" + ? "5 teams · 10,000 subscribed contacts" + : "25 teams · unlimited subscribed contacts"} +

+
+
+

+ {formatMinorAmount( + offer.amountMinor, + offer.currency, + )} +

+

+ per{" "} + {interval === "month" + ? "month" + : "year"} +

+
+
+
+ + + {offer.trialDays + ? `${offer.trialDays}-day trial included · hosted secure checkout` + : "Hosted secure checkout · change plan in SendLit; manage cards and cancellation in billing"} + +
+
+ ) : ( +
+ Choose a plan and billing interval to see + the current price. +
+ )} + + )} +
+ + +
+ + Secure payment handled by Dodo +
+
+ + +
+
+ +
+ ); +} + +function OrganizationPlanChangeDialog({ + organizationId, + billing, + open, + onOpenChange, + onChanged, +}: { + organizationId: string; + billing: OrganizationBilling; + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged: () => Promise; +}) { + const [catalog, setCatalog] = useState(null); + const [plan, setPlan] = useState<"pro" | "business">( + billing.plan === "business" ? "business" : "pro", + ); + const [interval, setInterval] = useState<"month" | "year">( + billing.billingInterval ?? "month", + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setPlan(billing.plan === "business" ? "business" : "pro"); + setInterval(billing.billingInterval ?? "month"); + setError(null); + setCatalog(null); + void (async () => { + try { + setCatalog(await getBillingCatalog()); + } catch (err) { + setError(errorMessage(err, "Unable to load billing plans")); + } + })(); + }, [open, billing.plan, billing.billingInterval]); + + const offer = catalog?.offers.find( + (item) => item.plan === plan && item.interval === interval, + ); + const unchanged = + billing.plan === plan && billing.billingInterval === interval; + const isUpgrade = + (plan === "business" && billing.plan === "pro") || + (plan === billing.plan && + billing.billingInterval === "month" && + interval === "year"); + + async function submit() { + if (!catalog?.catalogRevision || !offer || unchanged) return; + setLoading(true); + setError(null); + try { + const result: OrganizationPlanChange = + await createOrganizationBillingPlanChange(organizationId, { + plan, + interval, + catalogRevision: catalog.catalogRevision, + }); + if (result.paymentUrl) { + window.location.assign(result.paymentUrl); + return; + } + onOpenChange(false); + toast.success( + result.effectiveAt === "immediately" + ? "Plan change requested. We’ll enable it when the payment provider confirms it." + : "Plan change scheduled for the next billing date.", + ); + await onChanged(); + } catch (err) { + setError(errorMessage(err, "Unable to change plan")); + } finally { + setLoading(false); + } + } + + return ( + + +
+ +
+
+ +
+
+ + Change organization plan + + + Choose the plan and billing interval for + this organization. SendLit will apply the + change and confirm it from the provider + webhook. + +
+
+
+
+
+ {error ? {error} : null} + {!catalog ? ( + error ? ( +
+ Close this dialog and try again once billing + plans are available. +
+ ) : ( + + ) + ) : ( + <> +
+
+ + +
+
+ + +
+
+ {offer ? ( +
+
+
+

+ {plan === "pro" + ? "Pro workspace" + : "Business workspace"} +

+

+ {plan === "pro" + ? "5 teams · 10,000 subscribed contacts" + : "25 teams · unlimited subscribed contacts"} +

+
+
+

+ {formatMinorAmount( + offer.amountMinor, + offer.currency, + )} +

+

+ per{" "} + {interval === "month" + ? "month" + : "year"} +

+
+
+
+ + + {unchanged + ? "This is your current plan." + : isUpgrade + ? "Takes effect immediately; any prorated charge is handled securely by the payment provider." + : "Takes effect at the next billing date; your current entitlements remain available until then."} + +
+
+ ) : ( +
+ Choose a plan and billing interval to see + the current price. +
+ )} + + )} +
+ + + + +
+
+ ); +} + function TeamMailboxGrantRow({ organizationId, team, esps, grant, + sharedMailboxEnabled, + onUpgradeParent, onChanged, }: { organizationId: string; team: OrganizationTeam; esps: EspConfig[]; grant: OrganizationEspGrant | null; + sharedMailboxEnabled: boolean; + onUpgradeParent: () => void; onChanged: () => Promise; }) { const router = useRouter(); @@ -1901,12 +3725,18 @@ function TeamMailboxGrantRow({ Already a member ) : null} + onUpgradeParent()}> + + Upgrade parent organization + setGrantEditorOpen(true)} > - Mailbox grant settings + {sharedMailboxEnabled + ? "Mailbox grant settings" + : "Mailbox grant settings (upgrade required)"} - -
- - - Organization members - -

+ Organization access is separate from team membership and never grants access to a team's contacts or content. -

-
- -
+ + } + action={ + + } + /> {error && {error}} {loading ? ( @@ -2592,16 +4421,10 @@ function OrganizationOperationsSection({
- - - - Shared-delivery usage - -

- Only organization-delivery sends count toward this - pool. -

-
+ {loading || !usage ? ( @@ -2622,16 +4445,10 @@ function OrganizationOperationsSection({
- - - - Recent audit activity - -

- The latest 50 secret-free organization - administration events. -

-
+ {loading ? ( @@ -2673,41 +4490,35 @@ function OrganizationOperationsSection({
- -
- - - Transactional mail activity - -

- Counts are transactional only. Shared-delivery quota - remains separate. No email content is shown. -

-
- -
+ + + + + Last 1 day + Last 3 days + Last 7 days + Last 30 days + + + } + /> {loading || !mailActivity ? ( @@ -2853,16 +4664,20 @@ function OrganizationKeysSection({ organizationId, keys, loading, + billing, onChanged, }: { organizationId: string; keys: OrganizationApiKey[]; loading: boolean; + billing: OrganizationBilling | null; onChanged: () => Promise; }) { const [newKeyOpen, setNewKeyOpen] = useState(false); const [revokingId, setRevokingId] = useState(null); const [error, setError] = useState(null); + const keysEnabled = + billing === null || billing.entitlements.organizationApiKeys; const activeKeys = keys.filter((key) => !key.revokedAt); async function revoke(keyId: string) { setRevokingId(keyId); @@ -2878,24 +4693,33 @@ function OrganizationKeysSection({ } return ( - -
- - - Organization API keys - -

+ Use scoped keys for server-to-server provisioning. Secrets are shown once and are never stored in the browser. -

-
- -
+ + } + action={ + + } + /> + {!keysEnabled ? ( + + Organization API keys are available on Business and OSS. + Upgrade this organization to create one. + + ) : null} {error && {error}} {loading ? ( diff --git a/apps/web/app/api/proxy/[...path]/route.ts b/apps/web/app/api/proxy/[...path]/route.ts index 2fe4669..3d9f9df 100644 --- a/apps/web/app/api/proxy/[...path]/route.ts +++ b/apps/web/app/api/proxy/[...path]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "node:crypto"; import { API_URL } from "@/lib/config"; import { TEAM_ID_COOKIE } from "@/lib/tokens"; @@ -15,6 +16,20 @@ async function forward( const cookieHeader = req.headers.get("cookie"); const targetUrl = `${API_URL}/${path.join("/")}${req.nextUrl.search}`; const method = req.method; + const isMutation = ["POST", "PUT", "PATCH", "DELETE"].includes(method); + // Establish the readable double-submit cookie on the first same-origin + // request. It is not forwarded upstream on reads, preserving the proxy's + // existing cookie/privacy behavior; mutation requests forward it. + const csrfCookie = req.cookies.get("sendlit_csrf")?.value; + const csrfToken = csrfCookie || randomBytes(32).toString("base64url"); + const forwardedCookie = + isMutation && csrfToken + ? cookieHeader + ? csrfCookie + ? cookieHeader + : `${cookieHeader}; sendlit_csrf=${csrfToken}` + : `sendlit_csrf=${csrfToken}` + : cookieHeader; const hasBody = method !== "GET" && method !== "HEAD" && method !== "DELETE"; const body = hasBody ? await req.text() : undefined; @@ -34,7 +49,23 @@ async function forward( ...(teamId ? { "X-Sendlit-Team-Id": teamId } : {}), ...(body ? { "Content-Type": "application/json" } : {}), ...(forwardedFor ? { "X-Forwarded-For": forwardedFor } : {}), - ...(cookieHeader ? { Cookie: cookieHeader } : {}), + ...(req.headers.get("origin") + ? { Origin: req.headers.get("origin")! } + : {}), + ...(req.headers.get("referer") + ? { Referer: req.headers.get("referer")! } + : {}), + ...(forwardedCookie ? { Cookie: forwardedCookie } : {}), + ...(req.headers.get("x-sendlit-csrf") + ? { "X-Sendlit-CSRF": req.headers.get("x-sendlit-csrf")! } + : {}), + ...(req.headers.get("x-sendlit-billing-action-token") + ? { + "X-Sendlit-Billing-Action-Token": req.headers.get( + "x-sendlit-billing-action-token", + )!, + } + : {}), }, body, cache: "no-store", @@ -57,6 +88,18 @@ async function forward( if (upstream.status === 401) { res.headers.set("X-Auth-Error", "session_expired"); } + for (const header of ["Cache-Control", "ETag", "Referrer-Policy"]) { + const value = upstream.headers.get(header); + if (value) res.headers.set(header, value); + } + if (!csrfCookie) { + res.cookies.set("sendlit_csrf", csrfToken, { + httpOnly: false, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }); + } return res; } diff --git a/apps/web/components/dashboard/app-sidebar.tsx b/apps/web/components/dashboard/app-sidebar.tsx index b55b6ed..c830322 100644 --- a/apps/web/components/dashboard/app-sidebar.tsx +++ b/apps/web/components/dashboard/app-sidebar.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { Home, - Building2, Images, Mail, MailCheck, @@ -64,7 +63,6 @@ const ACTIVITY_NAV: NavMainItem[] = [ ]; const SECONDARY_NAV: NavMainItem[] = [ - { url: "/organizations", title: "Organizations", icon: Building2 }, { url: "/settings", title: "Settings", icon: Settings }, ]; diff --git a/apps/web/components/dashboard/banner.tsx b/apps/web/components/dashboard/banner.tsx index 38c1c89..131d318 100644 --- a/apps/web/components/dashboard/banner.tsx +++ b/apps/web/components/dashboard/banner.tsx @@ -5,7 +5,7 @@ export function Banner({ children, className, }: { - variant?: "error" | "success"; + variant?: "error" | "success" | "warning" | "info"; children: React.ReactNode; className?: string; }) { @@ -15,7 +15,11 @@ export function Banner({ "rounded-md px-3 py-2 text-sm", variant === "error" ? "border border-destructive/30 bg-background text-destructive" - : "bg-success-soft text-success", + : variant === "success" + ? "bg-success-soft text-success" + : variant === "warning" + ? "border border-amber-300/70 bg-amber-50 text-amber-950 dark:border-amber-800 dark:bg-amber-950/20 dark:text-amber-100" + : "border border-primary/20 bg-primary/5 text-foreground", className, )} > diff --git a/apps/web/components/dashboard/team-switcher.tsx b/apps/web/components/dashboard/team-switcher.tsx index 0664551..2a7f9a5 100644 --- a/apps/web/components/dashboard/team-switcher.tsx +++ b/apps/web/components/dashboard/team-switcher.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"; +import { Building2Icon, ChevronsUpDownIcon } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -124,10 +124,10 @@ export function TeamSwitcher({
- +
- Add team + Manage organizations
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 810ef2c..d1e5fec 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -717,10 +717,15 @@ async function organizationRequest( path: string, init: RequestInit = {}, ): Promise { + const csrf = + typeof document !== "undefined" + ? document.cookie.match(/(?:^|;\s*)sendlit_csrf=([^;]+)/)?.[1] + : undefined; const response = await fetch(`/api/proxy${path}`, { ...init, headers: { ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(csrf ? { "X-Sendlit-CSRF": decodeURIComponent(csrf) } : {}), ...init.headers, }, }); @@ -728,23 +733,62 @@ async function organizationRequest( if (response.status === 204) return undefined as T; return (await response.json()) as T; } + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; if (response.status === 401 && typeof window !== "undefined") { + if (body?.error === "recent_authentication_required") { + // A normal session refresh is not reauthentication. End the old + // session so the hosted email-OTP login must establish a new one. + await fetch("/api/auth/sign-out", { method: "POST" }).catch( + () => undefined, + ); + } window.location.href = "/login"; return new Promise(() => {}); } - const body = (await response.json().catch(() => null)) as { - error?: string; - } | null; throw new ApiError( response.status, body?.error || `Request failed (${response.status})`, ); } +type BillingAction = + | "organization_checkout" + | "checkout" + | "portal" + | "plan_change" + | "organization_close" + | "pending_hide"; + +/** Sensitive billing writes use a short-lived, single-use token bound to the + * current human session, action, and target organization. */ +async function billingActionRequest( + action: BillingAction, + target: string, + path: string, + init: RequestInit, +): Promise { + const authorization = await organizationRequest<{ + token: string; + expiresAt: string; + }>("/billing/action-token", { + method: "POST", + body: JSON.stringify({ action, target }), + }); + return organizationRequest(path, { + ...init, + headers: { + ...init.headers, + "X-Sendlit-Billing-Action-Token": authorization.token, + }, + }); +} + export interface Organization { organizationId: string; name: string; - status: "active" | "suspended" | "closed"; + status: "pending_payment" | "active" | "suspended" | "abandoned" | "closed"; createdAt: string; updatedAt: string; } @@ -874,7 +918,19 @@ export interface OrganizationDeliveryPolicy { } export function listOrganizations() { - return organizationRequest<{ items: Organization[] }>("/organizations"); + return organizationRequest<{ + items: Organization[]; + ownsFreeOrganization: boolean; + }>("/organizations"); +} + +export function abandonPendingOrganization(organizationId: string) { + return billingActionRequest( + "pending_hide", + organizationId, + `/organizations/${organizationId}/abandon`, + { method: "POST" }, + ); } export function createOrganization(name: string) { @@ -884,6 +940,71 @@ export function createOrganization(name: string) { }); } +export function createPaidOrganizationBillingCheckout(input: { + organizationName: string; + teamName: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; +}) { + return billingActionRequest<{ + organizationId: string; + checkoutUrl: string; + expiresAt: string; + }>("organization_checkout", "new", "/billing/organization-checkouts", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export interface SendingDomain { + domainId: string; + domain: string; + status: "pending" | "verified" | "revoked" | "failed"; + verifiedAt: string | null; + lastCheckedAt: string | null; + nextCheckAt: string | null; + challengeToken: string | null; + challengeRecordName: string | null; + challengeRecordValue: string | null; +} + +export function listOrganizationSendingDomains(organizationId: string) { + return organizationRequest<{ items: SendingDomain[] }>( + `/organizations/${organizationId}/sending-domains`, + ); +} + +export function createOrganizationSendingDomain( + organizationId: string, + domain: string, +) { + return organizationRequest( + `/organizations/${organizationId}/sending-domains`, + { method: "POST", body: JSON.stringify({ domain }) }, + ); +} + +export function verifyOrganizationSendingDomain( + organizationId: string, + domainId: string, +) { + return organizationRequest( + `/organizations/${organizationId}/sending-domains/${domainId}/verify`, + { method: "POST" }, + ); +} + +export function revokeOrganizationSendingDomain( + organizationId: string, + domainId: string, +) { + return organizationRequest( + `/organizations/${organizationId}/sending-domains/${domainId}`, + { method: "DELETE" }, + ); +} + export function updateOrganization(organizationId: string, name: string) { return organizationRequest( `/organizations/${organizationId}`, @@ -1211,6 +1332,148 @@ export function transitionOrganizationEspGrant( ); } +// ---- Billing -------------------------------------------------------------- + +export interface BillingOffer { + catalogKey: "pro_month" | "pro_year" | "business_month" | "business_year"; + plan: "pro" | "business"; + interval: "month" | "year"; + currency: string; + amountMinor: number; + trialDays: number; +} + +export interface BillingCatalog { + catalogRevision: number | null; + currency: string | null; + offers: BillingOffer[]; + checkoutAvailable: boolean; +} + +export type OrganizationPaymentStatus = + | "free" + | "checkout_pending" + | "trialing" + | "active" + | "past_due" + | "cancel_at_period_end" + | "cancelled" + | "expired"; + +export interface OrganizationPlanUsage { + plan: "oss" | "free" | "pro" | "business"; + paymentStatus: OrganizationPaymentStatus; + teams: number; + subscribedContacts: number; + monthlySends: number; + monthlySendsReserved: number; + bucketStartsAt: string; + bucketEndsAt: string; + teamsLimit: number | null; + subscribedContactsLimit: number | null; + monthlySendsLimit: number | null; +} + +export interface OrganizationEntitlements { + teamsLimit: number | null; + subscribedContactsLimit: number | null; + monthlySendsLimit: number | null; + sharedOrganizationMailbox: boolean; + provisioning: boolean; + organizationApiKeys: boolean; + marketingBranding: boolean; +} + +export interface OrganizationBilling { + plan: "oss" | "free" | "pro" | "business"; + billingInterval: "month" | "year" | null; + paymentStatus: OrganizationPaymentStatus; + trialEndsAt: string | null; + currentPeriodEndsAt: string | null; + cancelAtPeriodEnd: boolean; + graceEndsAt: string | null; + canManageBilling: boolean; + entitlements: OrganizationEntitlements; + usage: OrganizationPlanUsage; + pendingPlanChange: { + changeId: string; + targetPlan: "pro" | "business"; + targetInterval: "month" | "year"; + effectiveAt: "immediately" | "next_billing_date"; + } | null; +} + +export function getBillingCatalog() { + return organizationRequest("/billing/catalog"); +} + +export function getOrganizationBilling(organizationId: string) { + return organizationRequest( + `/organizations/${organizationId}/billing`, + ); +} + +export function createOrganizationBillingCheckout( + organizationId: string, + input: { + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + }, +) { + return billingActionRequest<{ checkoutUrl: string; expiresAt: string }>( + "checkout", + organizationId, + `/organizations/${organizationId}/billing/checkout`, + { method: "POST", body: JSON.stringify(input) }, + ); +} + +export function createOrganizationBillingPortal(organizationId: string) { + return billingActionRequest<{ portalUrl: string }>( + "portal", + organizationId, + `/organizations/${organizationId}/billing/portal`, + { method: "POST" }, + ); +} + +export interface OrganizationPlanChange { + changeId: string; + status: "pending" | "succeeded" | "failed" | "conflicted"; + targetPlan: "pro" | "business"; + targetInterval: "month" | "year"; + effectiveAt: "immediately" | "next_billing_date"; + paymentUrl: string | null; + completedAt: string | null; +} + +export function createOrganizationBillingPlanChange( + organizationId: string, + input: { + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + idempotencyKey?: string; + }, +) { + return billingActionRequest( + "plan_change", + organizationId, + `/organizations/${organizationId}/billing/plan-change`, + { method: "POST", body: JSON.stringify(input) }, + ); +} + +export function getOrganizationBillingPlanChange( + organizationId: string, + changeId: string, +) { + return organizationRequest( + `/organizations/${organizationId}/billing/plan-changes/${changeId}`, + ); +} + export type DeliveryEventType = | "accepted" | "delivered" diff --git a/apps/web/lib/navigation.ts b/apps/web/lib/navigation.ts new file mode 100644 index 0000000..a55d186 --- /dev/null +++ b/apps/web/lib/navigation.ts @@ -0,0 +1,3 @@ +export function reloadPage(): void { + window.location.reload(); +} diff --git a/eslint.config.cjs b/eslint.config.cjs index 56841be..1d68ec8 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -99,6 +99,12 @@ module.exports = defineConfig([ "react-hooks/set-state-in-effect": "off", }, }, + { + files: ["apps/api/scripts/**/*.{js,ts}"], + rules: { + "no-console": "off", + }, + }, prettier, globalIgnores([ "**/node_modules/", diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index 3373b8b..4727c03 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -116,6 +116,22 @@ import { updateTeamDeliverySettingsBodySchema, upsertEspGrantBodySchema, } from "./schemas/delivery"; +import { + billingCatalogSchema, + billingActionTokenBodySchema, + billingActionTokenResponseSchema, + billingCheckoutBodySchema, + billingCheckoutResponseSchema, + billingPortalResponseSchema, + billingPlanChangeBodySchema, + billingPlanChangeResponseSchema, + createSendingDomainBodySchema, + sendingDomainSchema, + organizationCheckoutBodySchema, + organizationCheckoutResponseSchema, + organizationBillingSchema, + organizationPlanUsageSchema, +} from "./schemas/billing"; const c = initContract(); @@ -133,7 +149,11 @@ const contactsContract = c.router( method: "POST", path: "/contacts", body: createContactBodySchema, - responses: { 201: contactSchema }, + responses: { + 201: contactSchema, + 402: errorSchema, + 409: errorSchema, + }, summary: "Create a contact", description: "Creates a contact (subscriber). If a contact with the same email already exists for this team, the existing contact is returned.", @@ -161,7 +181,12 @@ const contactsContract = c.router( method: "PATCH", path: "/contacts/:contactId", body: updateContactBodySchema, - responses: { 200: contactSchema, 404: errorSchema }, + responses: { + 200: contactSchema, + 402: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, summary: "Update a contact", }, addTag: { @@ -517,11 +542,13 @@ const transactionalContract = c.router( responses: { 202: sendEmailResponseSchema, 400: errorSchema, + 402: errorSchema, 422: z.union([ missingTemplateVariablesErrorSchema, templateNotTransactionalErrorSchema, errorSchema, ]), + 409: errorSchema, 429: errorSchema, }, summary: "Send a transactional email", @@ -712,6 +739,7 @@ const teamsContract = c.router( body: createTeamBodySchema, responses: { 201: teamSchema, + 402: errorSchema, 403: errorSchema, 409: errorSchema, }, @@ -765,6 +793,7 @@ const provisioningContract = c.router( 200: provisionTeamResponseSchema, 400: errorSchema, 401: errorSchema, + 402: errorSchema, 403: errorSchema, 409: errorSchema, 500: errorSchema, @@ -858,7 +887,10 @@ const organizationsContract = c.router( method: "GET", path: "/organizations", responses: { - 200: itemsList(organizationSchema), + 200: z.object({ + items: z.array(organizationSchema), + ownsFreeOrganization: z.boolean(), + }), 403: errorSchema, }, summary: "List organizations for the current user", @@ -867,7 +899,11 @@ const organizationsContract = c.router( method: "POST", path: "/organizations", body: createOrganizationBodySchema, - responses: { 201: organizationSchema, 403: errorSchema }, + responses: { + 201: organizationSchema, + 403: errorSchema, + 409: errorSchema, + }, summary: "Create an organization", }, get: { @@ -896,11 +932,28 @@ const organizationsContract = c.router( path: "/organizations/:organizationId", responses: { 204: c.noBody(), + 401: errorSchema, 403: errorSchema, 404: errorSchema, + 409: errorSchema, + 503: errorSchema, }, summary: "Close an organization", }, + abandon: { + method: "POST", + path: "/organizations/:organizationId/abandon", + body: c.noBody(), + responses: { + 204: c.noBody(), + 401: errorSchema, + 403: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, + summary: + "Hide a pending-payment organization after a cancelled checkout", + }, listMembers: { method: "GET", path: "/organizations/:organizationId/members", @@ -1295,6 +1348,47 @@ const organizationsContract = c.router( summary: "Transition a team's organization ESP grant; revoking detaches safe campaigns", }, + listSendingDomains: { + method: "GET", + path: "/organizations/:organizationId/sending-domains", + responses: { + 200: itemsList(sendingDomainSchema), + 403: errorSchema, + 404: errorSchema, + }, + summary: "List verified sending domains", + }, + createSendingDomain: { + method: "POST", + path: "/organizations/:organizationId/sending-domains", + body: createSendingDomainBodySchema, + responses: { + 201: sendingDomainSchema, + 400: errorSchema, + 403: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, + summary: "Create a DNS verification challenge for a sending domain", + }, + verifySendingDomain: { + method: "POST", + path: "/organizations/:organizationId/sending-domains/:domainId/verify", + body: c.noBody(), + responses: { + 200: sendingDomainSchema, + 403: errorSchema, + 404: errorSchema, + 422: errorSchema, + }, + summary: "Refresh DNS verification for a sending domain", + }, + revokeSendingDomain: { + method: "DELETE", + path: "/organizations/:organizationId/sending-domains/:domainId", + responses: { 204: c.noBody(), 403: errorSchema, 404: errorSchema }, + summary: "Revoke a sending domain", + }, }, { metadata: { tag: "Organizations" } }, ); @@ -1470,6 +1564,123 @@ const suppressionsContract = c.router( { metadata: { tag: "Delivery" } }, ); +const billingContract = c.router( + { + actionToken: { + method: "POST", + path: "/billing/action-token", + body: billingActionTokenBodySchema, + responses: { + 201: billingActionTokenResponseSchema, + 401: errorSchema, + 403: errorSchema, + 503: errorSchema, + }, + summary: "Issue a single-use token for a sensitive billing action", + }, + organizationCheckout: { + method: "POST", + path: "/billing/organization-checkouts", + body: organizationCheckoutBodySchema, + responses: { + 201: organizationCheckoutResponseSchema, + 400: errorSchema, + 401: errorSchema, + 402: errorSchema, + 403: errorSchema, + 409: errorSchema, + 503: errorSchema, + }, + summary: "Create a paid organization and hosted checkout", + }, + catalog: { + method: "GET", + path: "/billing/catalog", + responses: { 200: billingCatalogSchema, 503: errorSchema }, + summary: "Read the active billing catalog", + description: + "Returns environment-configured offers without provider product IDs. Amounts are integer minor units.", + }, + organizationBilling: { + method: "GET", + path: "/organizations/:organizationId/billing", + responses: { + 200: organizationBillingSchema, + 403: errorSchema, + 404: errorSchema, + }, + summary: "Read organization plan and billing status", + }, + organizationPlanUsage: { + method: "GET", + path: "/organizations/:organizationId/plan-usage", + responses: { + 200: organizationPlanUsageSchema, + 403: errorSchema, + 404: errorSchema, + }, + summary: "Read organization plan usage", + }, + checkout: { + method: "POST", + path: "/organizations/:organizationId/billing/checkout", + body: billingCheckoutBodySchema, + responses: { + 201: billingCheckoutResponseSchema, + 400: errorSchema, + 401: errorSchema, + 402: errorSchema, + 403: errorSchema, + 409: errorSchema, + 503: errorSchema, + }, + summary: "Create a hosted organization checkout", + }, + portal: { + method: "POST", + path: "/organizations/:organizationId/billing/portal", + body: c.noBody(), + responses: { + 201: billingPortalResponseSchema, + 401: errorSchema, + 402: errorSchema, + 403: errorSchema, + 404: errorSchema, + 503: errorSchema, + }, + summary: "Create the billing manager's hosted portal session", + }, + changePlan: { + method: "POST", + path: "/organizations/:organizationId/billing/plan-change", + body: billingPlanChangeBodySchema, + responses: { + 202: billingPlanChangeResponseSchema, + 200: billingPlanChangeResponseSchema, + 400: errorSchema, + 401: errorSchema, + 402: errorSchema, + 403: errorSchema, + 404: errorSchema, + 409: errorSchema, + 503: errorSchema, + }, + summary: "Request a SendLit-owned organization plan change", + }, + getPlanChange: { + method: "GET", + path: "/organizations/:organizationId/billing/plan-changes/:changeId", + responses: { + 200: billingPlanChangeResponseSchema, + 403: errorSchema, + 404: errorSchema, + }, + summary: "Read a plan-change request status", + }, + }, + { metadata: { tag: "Billing" } }, +); + export const contract = c.router({ contacts: contactsContract, segments: segmentsContract, @@ -1486,6 +1697,7 @@ export const contract = c.router({ feedback: feedbackContract, deliveryEvents: deliveryEventsContract, suppressions: suppressionsContract, + billing: billingContract, }); export type Contract = typeof contract; diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 2b3e0dc..f3ed6c0 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -15,3 +15,4 @@ export * from "./schemas/media"; export * from "./schemas/feedback"; export * from "./schemas/delivery-events"; export * from "./schemas/suppressions"; +export * from "./schemas/billing"; diff --git a/packages/api-contract/src/schemas/billing.ts b/packages/api-contract/src/schemas/billing.ts new file mode 100644 index 0000000..c21c978 --- /dev/null +++ b/packages/api-contract/src/schemas/billing.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; + +export const billingPlanSchema = z.enum(["oss", "free", "pro", "business"]); +export const billingIntervalSchema = z.enum(["month", "year"]); +export const paymentStatusSchema = z.enum([ + "free", + "checkout_pending", + "trialing", + "active", + "past_due", + "cancel_at_period_end", + "cancelled", + "expired", +]); + +export const billingOfferSchema = z.object({ + catalogKey: z.enum([ + "pro_month", + "pro_year", + "business_month", + "business_year", + ]), + plan: z.enum(["pro", "business"]), + interval: billingIntervalSchema, + currency: z.string().regex(/^[A-Z]{3}$/), + amountMinor: z.number().int().positive(), + trialDays: z.number().int().nonnegative(), +}); + +export const billingCatalogSchema = z.object({ + catalogRevision: z.number().int().positive().nullable(), + currency: z + .string() + .regex(/^[A-Z]{3}$/) + .nullable(), + offers: z.array(billingOfferSchema), + checkoutAvailable: z.boolean(), +}); + +export const organizationPlanUsageSchema = z.object({ + plan: billingPlanSchema, + paymentStatus: paymentStatusSchema, + teams: z.number().int().nonnegative(), + subscribedContacts: z.number().int().nonnegative(), + monthlySends: z.number().int().nonnegative(), + monthlySendsReserved: z.number().int().nonnegative(), + bucketStartsAt: z.string(), + bucketEndsAt: z.string(), + teamsLimit: z.number().int().positive().nullable(), + subscribedContactsLimit: z.number().int().positive().nullable(), + monthlySendsLimit: z.number().int().positive().nullable(), +}); + +export const billingPlanChangeSummarySchema = z.object({ + changeId: z.string(), + targetPlan: z.enum(["pro", "business"]), + targetInterval: billingIntervalSchema, + effectiveAt: z.enum(["immediately", "next_billing_date"]), +}); + +export const organizationBillingSchema = z.object({ + plan: billingPlanSchema, + billingInterval: billingIntervalSchema.nullable(), + paymentStatus: paymentStatusSchema, + trialEndsAt: z.string().nullable(), + currentPeriodEndsAt: z.string().nullable(), + cancelAtPeriodEnd: z.boolean(), + graceEndsAt: z.string().nullable(), + canManageBilling: z.boolean(), + entitlements: z.object({ + teamsLimit: z.number().int().positive().nullable(), + subscribedContactsLimit: z.number().int().positive().nullable(), + monthlySendsLimit: z.number().int().positive().nullable(), + sharedOrganizationMailbox: z.boolean(), + provisioning: z.boolean(), + organizationApiKeys: z.boolean(), + marketingBranding: z.boolean(), + }), + usage: organizationPlanUsageSchema, + pendingPlanChange: billingPlanChangeSummarySchema.nullable(), +}); + +export const billingCheckoutBodySchema = z.object({ + plan: z.enum(["pro", "business"]), + interval: billingIntervalSchema, + catalogRevision: z.number().int().positive(), +}); + +export const billingCheckoutResponseSchema = z.object({ + checkoutUrl: z.string().url(), + expiresAt: z.string(), +}); + +export const billingPortalResponseSchema = z.object({ + portalUrl: z.string().url(), +}); + +export const billingActionSchema = z.enum([ + "organization_checkout", + "checkout", + "portal", + "plan_change", + "organization_close", + "pending_hide", +]); + +export const billingActionTokenBodySchema = z.object({ + action: billingActionSchema, + target: z.string().trim().min(1).max(300), +}); + +export const billingActionTokenResponseSchema = z.object({ + token: z.string().min(32), + expiresAt: z.string().datetime(), +}); + +export const billingPlanChangeBodySchema = z.object({ + plan: z.enum(["pro", "business"]), + interval: billingIntervalSchema, + catalogRevision: z.number().int().positive(), + idempotencyKey: z.string().trim().min(1).max(256).optional(), +}); + +export const billingPlanChangeResponseSchema = z.object({ + changeId: z.string(), + status: z.enum(["pending", "succeeded", "failed", "conflicted"]), + targetPlan: z.enum(["pro", "business"]), + targetInterval: billingIntervalSchema, + effectiveAt: z.enum(["immediately", "next_billing_date"]), + paymentUrl: z.string().url().nullable(), + completedAt: z.string().nullable(), +}); + +export const organizationCheckoutBodySchema = billingCheckoutBodySchema.extend({ + organizationName: z.string().trim().min(1).max(120), + teamName: z.string().trim().min(1).max(120), +}); + +export const organizationCheckoutResponseSchema = + billingCheckoutResponseSchema.extend({ + organizationId: z.string(), + }); + +export const sendingDomainSchema = z.object({ + domainId: z.string(), + domain: z.string(), + status: z.enum(["pending", "verified", "revoked", "failed"]), + verifiedAt: z.string().nullable(), + lastCheckedAt: z.string().nullable(), + nextCheckAt: z.string().nullable(), + challengeToken: z.string().nullable(), + challengeRecordName: z.string().nullable(), + challengeRecordValue: z.string().nullable(), +}); + +export const createSendingDomainBodySchema = z.object({ + domain: z.string().trim().min(1).max(253), +}); diff --git a/packages/api-contract/src/schemas/organizations.ts b/packages/api-contract/src/schemas/organizations.ts index 4461130..75320b0 100644 --- a/packages/api-contract/src/schemas/organizations.ts +++ b/packages/api-contract/src/schemas/organizations.ts @@ -5,7 +5,13 @@ export const organizationRoleSchema = z.enum(["owner", "admin", "member"]); export const organizationSchema = z.object({ organizationId: z.string(), name: z.string(), - status: z.enum(["active", "suspended", "closed"]), + status: z.enum([ + "pending_payment", + "active", + "suspended", + "abandoned", + "closed", + ]), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/packages/email-blocks/src/footer/block.tsx b/packages/email-blocks/src/footer/block.tsx index 4468357..d87c55a 100644 --- a/packages/email-blocks/src/footer/block.tsx +++ b/packages/email-blocks/src/footer/block.tsx @@ -61,6 +61,9 @@ export function FooterBlock({ {unsubscribeLabel}
+ {footer.brandingText ? ( +
{footer.brandingText}
+ ) : null} ); } diff --git a/packages/email-blocks/src/footer/types.ts b/packages/email-blocks/src/footer/types.ts index 1902ca0..caec62a 100644 --- a/packages/email-blocks/src/footer/types.ts +++ b/packages/email-blocks/src/footer/types.ts @@ -13,6 +13,8 @@ export interface SendLitEmailRenderContext { footer?: { mailingAddress: string; unsubscribeUrl: string; + /** Server-owned plan branding; never part of editable template data. */ + brandingText?: string; }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7bec29..4127d0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 + dodopayments: + specifier: ^2.48.0 + version: 2.48.0 dotenv: specifier: ^17.2.3 version: 17.4.2 @@ -667,7 +670,7 @@ importers: version: 10.5.2(postcss@8.5.16) eslint: specifier: ^8.57.0 - version: 8.57.1(supports-color@5.5.0) + version: 8.57.1 postcss: specifier: ^8.4.35 version: 8.5.16 @@ -688,10 +691,10 @@ importers: version: 4.9.5 typescript-eslint: specifier: ^7.4.0 - version: 7.18.0(eslint@8.57.1(supports-color@5.5.0))(supports-color@5.5.0)(typescript@4.9.5) + version: 7.18.0(eslint@8.57.1)(typescript@4.9.5) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/email-editor: dependencies: @@ -758,7 +761,7 @@ importers: version: 10.5.2(postcss@8.5.16) eslint: specifier: ^8.57.0 - version: 8.57.1 + version: 8.57.1(supports-color@5.5.0) postcss: specifier: ^8.4.35 version: 8.5.16 @@ -779,10 +782,10 @@ importers: version: 4.9.5 typescript-eslint: specifier: ^7.4.0 - version: 7.18.0(eslint@8.57.1)(typescript@4.9.5) + version: 7.18.0(eslint@8.57.1(supports-color@5.5.0))(supports-color@5.5.0)(typescript@4.9.5) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -5122,6 +5125,10 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dodopayments@2.48.0: + resolution: {integrity: sha512-MDsHBdhQuMgYmIykI+2IJeATGNXM7bJVFs4FBUAhmpLNjnxZEqFlmnpUHkWQI6kIHpWhZZXC1cCKpGJVBU6tfg==} + hasBin: true + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -13022,6 +13029,10 @@ snapshots: dependencies: esutils: 2.0.3 + dodopayments@2.48.0: + dependencies: + standardwebhooks: 1.0.0 + dom-accessibility-api@0.5.16: {} dom-serializer@2.0.0: From 62d342d09dfe899322a6c3874e10b841df1bb621 Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 1 Sep 2026 23:04:25 +0530 Subject: [PATCH 2/2] Adopt generated billing schema and workflow engine --- apps/api/Dockerfile | 1 + apps/api/billing.config.ts | 50 + .../pricing-plans-and-payments-integration.md | 6 +- apps/api/drizzle.config.ts | 6 +- .../{0005_many_shape.sql => 0005_billing.sql} | 540 +- apps/api/drizzle/meta/0005_snapshot.json | 10214 ++++++++-------- apps/api/drizzle/meta/_journal.json | 4 +- apps/api/package.json | 6 +- apps/api/scripts/billing.ts | 89 +- apps/api/src/billing/alerts.ts | 10 +- apps/api/src/billing/authorization-port.ts | 84 + apps/api/src/billing/catalog-store.test.ts | 15 +- apps/api/src/billing/catalog-store.ts | 230 +- apps/api/src/billing/catalog.ts | 31 +- apps/api/src/billing/checkout.ts | 604 +- apps/api/src/billing/engine.ts | 153 + apps/api/src/billing/entitlements.test.ts | 33 +- apps/api/src/billing/entitlements.ts | 59 +- apps/api/src/billing/notifications.ts | 15 +- apps/api/src/billing/plan-change.test.ts | 104 +- apps/api/src/billing/plan-change.ts | 452 +- apps/api/src/billing/portal.ts | 86 +- apps/api/src/billing/product-effects.ts | 151 + apps/api/src/billing/provider-contract.ts | 164 +- apps/api/src/billing/provider-registry.ts | 13 +- apps/api/src/billing/provider.ts | 173 +- .../src/billing/providers/dodo/index.test.ts | 69 +- apps/api/src/billing/providers/dodo/index.ts | 473 +- .../src/billing/providers/fake/index.test.ts | 4 +- apps/api/src/billing/providers/fake/index.ts | 393 +- apps/api/src/billing/reconciliation.ts | 225 +- apps/api/src/billing/reputation.ts | 6 +- apps/api/src/billing/routes.ts | 81 +- apps/api/src/billing/security.test.ts | 64 + apps/api/src/billing/security.ts | 55 +- apps/api/src/billing/webhook-retry.ts | 40 +- apps/api/src/billing/webhooks/processor.ts | 217 +- apps/api/src/billing/webhooks/routes.ts | 100 +- apps/api/src/db/billing-extensions.ts | 48 + apps/api/src/db/billing.generated.ts | 529 + apps/api/src/db/schema-core.ts | 2174 ++++ apps/api/src/db/schema.ts | 2709 +--- apps/api/src/organization/queries.test.ts | 30 +- apps/api/src/organization/queries.ts | 101 +- apps/api/src/test/db.ts | 11 +- apps/web/lib/api.test.ts | 44 + apps/web/lib/api.ts | 8 +- pnpm-lock.yaml | 63 +- pnpm-workspace.yaml | 1 + 49 files changed, 10022 insertions(+), 10716 deletions(-) create mode 100644 apps/api/billing.config.ts rename apps/api/drizzle/{0005_many_shape.sql => 0005_billing.sql} (65%) create mode 100644 apps/api/src/billing/authorization-port.ts create mode 100644 apps/api/src/billing/engine.ts create mode 100644 apps/api/src/billing/product-effects.ts create mode 100644 apps/api/src/db/billing-extensions.ts create mode 100644 apps/api/src/db/billing.generated.ts create mode 100644 apps/api/src/db/schema-core.ts diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index f0c86f6..04d0496 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -22,6 +22,7 @@ COPY --from=deps /app/ ./ RUN pnpm --filter=@sendlit/email-editor build RUN pnpm --filter=@sendlit/api-contract build RUN pnpm --filter=@sendlit/email-blocks build +RUN pnpm --filter=@sendlit/api billing:generate RUN pnpm --filter=@sendlit/api build FROM base AS runner diff --git a/apps/api/billing.config.ts b/apps/api/billing.config.ts new file mode 100644 index 0000000..3bf8147 --- /dev/null +++ b/apps/api/billing.config.ts @@ -0,0 +1,50 @@ +import { defineBillingConfig } from "@codelitdev/billing/config"; + +/** Schema description for `codelit-billing generate`. Output is committed and + * applied through drizzle-kit; SendLit does not hand-write canonical tables. */ +export default defineBillingConfig({ + dialect: "postgresql", + adapter: "drizzle", + output: "./src/db/billing.generated.ts", + billableEntity: { + modelName: "organization", + tableImport: "./schema-core", + tableExport: "organizations", + idColumn: "id", + idType: "uuid", + onDelete: "restrict", + }, + payer: { + modelName: "user", + tableImport: "./schema-core", + tableExport: "user", + idColumn: "id", + idType: "text", + onDelete: "restrict", + }, + planIds: ["pro", "business"], + requiredOfferKeys: [ + "pro_month", + "pro_year", + "business_month", + "business_year", + ], + additionalFields: { + planStates: { + plan: { type: "text", nullable: false }, + teamsLimitOverride: { type: "integer", nullable: true }, + contactsLimitOverride: { type: "integer", nullable: true }, + firstPaidActivatedAt: { type: "timestamp", nullable: true }, + rampStage: { type: "integer", nullable: false }, + rampCleanStageDays: { type: "integer", nullable: false }, + rampEvaluatedAt: { type: "timestamp", nullable: true }, + }, + checkoutAttempts: { + pendingTeamName: { type: "text", nullable: true }, + }, + subscriptions: { + pastDueAt: { type: "timestamp", nullable: true }, + graceEndsAt: { type: "timestamp", nullable: true }, + }, + }, +}); diff --git a/apps/api/docs/pricing-plans-and-payments-integration.md b/apps/api/docs/pricing-plans-and-payments-integration.md index f9fa400..f131fad 100644 --- a/apps/api/docs/pricing-plans-and-payments-integration.md +++ b/apps/api/docs/pricing-plans-and-payments-integration.md @@ -1642,7 +1642,11 @@ Organizations, or remove the tab entirely. Accounts are never billed. require authentication within the last 15 minutes. Otherwise require a verified-email OTP/WebAuthn reauthentication and issue a single-purpose, five-minute server action token bound to user, organization, action, and - session. A normal long-lived session is insufficient. + session. A normal long-lived session is insufficient. Resuming checkout for + an organization already in `pending_payment` is the exception: the user + already started that attempt, so the existing human session may issue the + action token without a 15-minute reauthentication. The dashboard must not + treat `recent_authentication_required` as a dead session (no sign-out). - Cookie-authenticated billing mutations require the application's CSRF token and an exact allowlisted `Origin` (with a same-origin `Referer` fallback only where the browser omits Origin). Provider webhook routes are exempt from CSRF diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 0ce123f..d8b67b5 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/db/schema.ts", + schema: [ + "./src/db/schema-core.ts", + "./src/db/billing.generated.ts", + "./src/db/billing-extensions.ts", + ], out: "./drizzle", dialect: "postgresql", dbCredentials: { diff --git a/apps/api/drizzle/0005_many_shape.sql b/apps/api/drizzle/0005_billing.sql similarity index 65% rename from apps/api/drizzle/0005_many_shape.sql rename to apps/api/drizzle/0005_billing.sql index 7da260b..6f1a8ef 100644 --- a/apps/api/drizzle/0005_many_shape.sql +++ b/apps/api/drizzle/0005_billing.sql @@ -1,12 +1,80 @@ -CREATE TABLE IF NOT EXISTS "billing_catalog_revision_items" ( +CREATE TABLE IF NOT EXISTS "plan_send_reservations" ( + "id" uuid PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "outbound_message_id" uuid NOT NULL, + "bucket_id" uuid NOT NULL, + "amount" integer DEFAULT 1 NOT NULL, + "state" text DEFAULT 'reserved' NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "committed_at" timestamp with time zone, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plan_send_reservations_amount_check" CHECK ("plan_send_reservations"."amount" > 0), + CONSTRAINT "plan_send_reservations_state_check" CHECK ("plan_send_reservations"."state" IN ('reserved', 'committed', 'released')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "plan_send_usage_buckets" ( + "id" uuid PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "bucket_month" timestamp with time zone NOT NULL, + "committed" integer DEFAULT 0 NOT NULL, + "reserved" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plan_send_usage_buckets_count_check" CHECK ("plan_send_usage_buckets"."committed" >= 0 AND "plan_send_usage_buckets"."reserved" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sending_domains" ( "id" uuid PRIMARY KEY NOT NULL, + "domain_id" text NOT NULL, + "organization_id" uuid NOT NULL, + "domain" text NOT NULL, + "challenge_token_hash" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "verified_at" timestamp with time zone, + "last_checked_at" timestamp with time zone, + "next_check_at" timestamp with time zone, + "failed_check_count" integer DEFAULT 0 NOT NULL, + "first_failed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sending_domains_domain_id_unique" UNIQUE("domain_id"), + CONSTRAINT "sending_domains_domain_id_check" CHECK ("sending_domains"."domain_id" ~ '^domain_'), + CONSTRAINT "sending_domains_status_check" CHECK ("sending_domains"."status" IN ('pending', 'verified', 'revoked', 'failed')), + CONSTRAINT "sending_domains_failed_check_count_check" CHECK ("sending_domains"."failed_check_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "team_sending_controls" ( + "id" uuid PRIMARY KEY NOT NULL, + "team_id" uuid NOT NULL, + "status" text DEFAULT 'normal' NOT NULL, + "reason_code" text, + "source" text DEFAULT 'automatic' NOT NULL, + "entered_at" timestamp with time zone, + "evaluated_at" timestamp with time zone, + "minimum_hold_until" timestamp with time zone, + "operator_user_id" text, + "operator_reason" text, + "overridden_at" timestamp with time zone, + "clean_evaluation_days" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "team_sending_controls_team_id_unique" UNIQUE("team_id"), + CONSTRAINT "team_sending_controls_status_check" CHECK ("team_sending_controls"."status" IN ('normal', 'warned', 'marketing_paused', 'all_paused')), + CONSTRAINT "team_sending_controls_source_check" CHECK ("team_sending_controls"."source" IN ('automatic', 'operator')), + CONSTRAINT "team_sending_controls_clean_days_check" CHECK ("team_sending_controls"."clean_evaluation_days" >= 0) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_catalog_revision_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "catalog_revision_id" uuid NOT NULL, - "catalog_key" text NOT NULL, + "offer_key" text NOT NULL, "billing_price_entry_id" uuid NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "billing_catalog_revisions" ( - "id" uuid PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "revision" integer NOT NULL, "checkout_provider" text NOT NULL, "status" text DEFAULT 'pending_verification' NOT NULL, @@ -21,13 +89,15 @@ CREATE TABLE IF NOT EXISTS "billing_catalog_revisions" ( ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "billing_checkout_attempts" ( - "id" uuid PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "attempt_id" text NOT NULL, - "organization_id" uuid NOT NULL, - "payer_user_id" text NOT NULL, + "billable_entity_id" uuid NOT NULL, + "payer_id" text NOT NULL, + "payer_email" text DEFAULT '' NOT NULL, + "return_url" text DEFAULT '' NOT NULL, "provider" text NOT NULL, "catalog_revision" integer NOT NULL, - "catalog_key" text NOT NULL, + "offer_key" text NOT NULL, "requested_plan" text NOT NULL, "requested_interval" text NOT NULL, "billing_price_entry_id" uuid NOT NULL, @@ -43,33 +113,91 @@ CREATE TABLE IF NOT EXISTS "billing_checkout_attempts" ( "completed_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "pending_team_name" text, CONSTRAINT "billing_checkout_attempts_attempt_id_unique" UNIQUE("attempt_id"), CONSTRAINT "billing_checkout_attempts_status_check" CHECK ("billing_checkout_attempts"."status" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')), - CONSTRAINT "billing_checkout_attempts_amount_check" CHECK ("billing_checkout_attempts"."quoted_amount_minor" > 0) + CONSTRAINT "billing_checkout_attempts_amount_check" CHECK ("billing_checkout_attempts"."quoted_amount_minor" > 0), + CONSTRAINT "billing_checkout_attempts_plan_check" CHECK ("billing_checkout_attempts"."requested_plan" IN ('pro', 'business')), + CONSTRAINT "billing_checkout_attempts_interval_check" CHECK ("billing_checkout_attempts"."requested_interval" IN ('month', 'year')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_plan_change_attempts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "change_id" text NOT NULL, + "billable_entity_id" uuid NOT NULL, + "subscription_id" uuid NOT NULL, + "actor_id" text NOT NULL, + "payer_id" text NOT NULL, + "provider" text NOT NULL, + "idempotency_key" text NOT NULL, + "current_catalog_revision" integer NOT NULL, + "current_billing_price_entry_id" uuid NOT NULL, + "current_plan" text NOT NULL, + "current_interval" text NOT NULL, + "target_catalog_revision" integer NOT NULL, + "target_billing_price_entry_id" uuid NOT NULL, + "target_plan" text NOT NULL, + "target_interval" text NOT NULL, + "target_offer_key" text NOT NULL, + "effective_at" text NOT NULL, + "proration_mode" text NOT NULL, + "provider_payment_id" text, + "payment_url_encrypted" text, + "status" text DEFAULT 'creating' NOT NULL, + "last_error" text, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "billing_plan_change_attempts_change_id_unique" UNIQUE("change_id"), + CONSTRAINT "billing_plan_change_attempts_status_check" CHECK ("billing_plan_change_attempts"."status" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')), + CONSTRAINT "billing_plan_change_attempts_effective_at_check" CHECK ("billing_plan_change_attempts"."effective_at" IN ('immediately', 'next_billing_date')), + CONSTRAINT "billing_plan_change_attempts_proration_mode_check" CHECK ("billing_plan_change_attempts"."proration_mode" IN ('prorated_immediately', 'do_not_bill')), + CONSTRAINT "billing_plan_change_attempts_current_plan_check" CHECK ("billing_plan_change_attempts"."current_plan" IN ('pro', 'business')), + CONSTRAINT "billing_plan_change_attempts_target_plan_check" CHECK ("billing_plan_change_attempts"."target_plan" IN ('pro', 'business')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "billing_plan_states" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "billable_entity_id" uuid NOT NULL, + "active_subscription_id" uuid, + "projection_version" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "plan" text NOT NULL, + "teams_limit_override" integer, + "contacts_limit_override" integer, + "first_paid_activated_at" timestamp with time zone, + "ramp_stage" integer NOT NULL, + "ramp_clean_stage_days" integer NOT NULL, + "ramp_evaluated_at" timestamp with time zone, + CONSTRAINT "billing_plan_states_billable_entity_id_unique" UNIQUE("billable_entity_id") ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "billing_price_entries" ( - "id" uuid PRIMARY KEY NOT NULL, - "catalog_key" text NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "offer_key" text NOT NULL, "plan" text NOT NULL, "billing_interval" text NOT NULL, "currency" text NOT NULL, "amount_minor" integer NOT NULL, + "provider_trial_days" integer DEFAULT 0 NOT NULL, "provider" text NOT NULL, "provider_product_id" text NOT NULL, "verified_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "billing_price_entries_amount_check" CHECK ("billing_price_entries"."amount_minor" > 0), + CONSTRAINT "billing_price_entries_trial_days_check" CHECK ("billing_price_entries"."provider_trial_days" >= 0), CONSTRAINT "billing_price_entries_currency_check" CHECK ("billing_price_entries"."currency" ~ '^[A-Z]{3}$'), CONSTRAINT "billing_price_entries_plan_check" CHECK ("billing_price_entries"."plan" IN ('pro', 'business')), CONSTRAINT "billing_price_entries_interval_check" CHECK ("billing_price_entries"."billing_interval" IN ('month', 'year')) ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "billing_provider_customers" ( - "id" uuid PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "provider" text NOT NULL, - "user_id" text NOT NULL, + "payer_id" text NOT NULL, + "payer_email" text DEFAULT '' NOT NULL, "provider_customer_id" text, "idempotency_key" text NOT NULL, "status" text DEFAULT 'creating' NOT NULL, @@ -79,69 +207,40 @@ CREATE TABLE IF NOT EXISTS "billing_provider_customers" ( CONSTRAINT "billing_provider_customers_status_check" CHECK ("billing_provider_customers"."status" IN ('creating', 'active', 'conflicted')) ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "billing_trial_claims" ( - "id" uuid PRIMARY KEY NOT NULL, - "user_id" text NOT NULL, - "verified_email_fingerprint" text NOT NULL, - "fingerprint_key_version" text NOT NULL, - "trial_key" text NOT NULL, - "organization_id" uuid NOT NULL, - "checkout_attempt_id" uuid, - "status" text DEFAULT 'reserved' NOT NULL, - "expires_at" timestamp with time zone, - "redeemed_at" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "billing_trial_claims_status_check" CHECK ("billing_trial_claims"."status" IN ('reserved', 'redeemed', 'released')) -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "billing_webhook_events" ( - "id" uuid PRIMARY KEY NOT NULL, +CREATE TABLE IF NOT EXISTS "billing_reconciliation_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "provider" text NOT NULL, - "provider_event_id" text NOT NULL, - "event_type" text NOT NULL, - "occurred_at" timestamp with time zone NOT NULL, - "payload_encrypted" text, - "payload_key_version" text, + "checkout_attempt_id" uuid, + "plan_change_attempt_id" uuid, + "subscription_id" uuid, + "provider_customer_id" uuid, + "operation" text DEFAULT 'reconcile' NOT NULL, "status" text DEFAULT 'pending' NOT NULL, - "processing_attempts" integer DEFAULT 0 NOT NULL, - "last_error" text, + "attempt_count" integer DEFAULT 0 NOT NULL, "available_at" timestamp with time zone DEFAULT now() NOT NULL, "locked_at" timestamp with time zone, "lease_expires_at" timestamp with time zone, "worker_id" text, - "received_at" timestamp with time zone DEFAULT now() NOT NULL, - "processed_at" timestamp with time zone, - CONSTRAINT "billing_webhook_events_status_check" CHECK ("billing_webhook_events"."status" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')) -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "organization_plan_states" ( - "id" uuid PRIMARY KEY NOT NULL, - "organization_id" uuid NOT NULL, - "plan" text DEFAULT 'free' NOT NULL, - "active_subscription_id" uuid, - "teams_limit_override" integer, - "contacts_limit_override" integer, - "projection_version" integer DEFAULT 0 NOT NULL, - "first_paid_activated_at" timestamp with time zone, + "last_error" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "organization_plan_states_organization_id_unique" UNIQUE("organization_id"), - CONSTRAINT "organization_plan_states_plan_check" CHECK ("organization_plan_states"."plan" IN ('free', 'pro', 'business')), - CONSTRAINT "organization_plan_states_teams_override_check" CHECK ("organization_plan_states"."teams_limit_override" IS NULL OR "organization_plan_states"."teams_limit_override" > 0), - CONSTRAINT "organization_plan_states_contacts_override_check" CHECK ("organization_plan_states"."contacts_limit_override" IS NULL OR "organization_plan_states"."contacts_limit_override" > 0) + CONSTRAINT "billing_reconciliation_jobs_exactly_one_subject" CHECK (((CASE WHEN "billing_reconciliation_jobs"."checkout_attempt_id" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN "billing_reconciliation_jobs"."plan_change_attempt_id" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN "billing_reconciliation_jobs"."subscription_id" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN "billing_reconciliation_jobs"."provider_customer_id" IS NOT NULL THEN 1 ELSE 0 END)) = 1), + CONSTRAINT "billing_reconciliation_jobs_status_check" CHECK ("billing_reconciliation_jobs"."status" IN ('pending', 'processing', 'failed', 'completed', 'quarantined')), + CONSTRAINT "billing_reconciliation_jobs_operation_check" CHECK ("billing_reconciliation_jobs"."operation" = 'reconcile' OR ("billing_reconciliation_jobs"."operation" = 'cancellation' AND "billing_reconciliation_jobs"."subscription_id" IS NOT NULL)) ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "organization_subscriptions" ( - "id" uuid PRIMARY KEY NOT NULL, - "organization_id" uuid NOT NULL, +CREATE TABLE IF NOT EXISTS "billing_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "billable_entity_id" uuid NOT NULL, "billing_customer_id" uuid NOT NULL, - "billing_manager_user_id" text NOT NULL, + "payer_id" text NOT NULL, + "origin_checkout_attempt_id" uuid, "provider" text NOT NULL, "provider_subscription_id" text NOT NULL, "provider_product_id" text NOT NULL, "billing_price_entry_id" uuid NOT NULL, - "catalog_key" text NOT NULL, + "catalog_revision" integer NOT NULL, + "offer_key" text NOT NULL, "plan" text NOT NULL, "billing_interval" text NOT NULL, "status" text DEFAULT 'pending' NOT NULL, @@ -149,347 +248,294 @@ CREATE TABLE IF NOT EXISTS "organization_subscriptions" ( "current_period_ends_at" timestamp with time zone, "paid_through_at" timestamp with time zone, "trial_ends_at" timestamp with time zone, - "past_due_at" timestamp with time zone, - "grace_ends_at" timestamp with time zone, "cancel_at_period_end" boolean DEFAULT false NOT NULL, "is_entitlement_source" boolean DEFAULT false NOT NULL, - "last_provider_event_at" timestamp with time zone, + "provider_occurred_at" timestamp with time zone, + "provider_version" text, + "last_observed_at" timestamp with time zone, "last_reconciled_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "organization_subscriptions_status_check" CHECK ("organization_subscriptions"."status" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')), - CONSTRAINT "organization_subscriptions_plan_check" CHECK ("organization_subscriptions"."plan" IN ('pro', 'business')), - CONSTRAINT "organization_subscriptions_interval_check" CHECK ("organization_subscriptions"."billing_interval" IN ('month', 'year')) -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "plan_send_reservations" ( - "id" uuid PRIMARY KEY NOT NULL, - "organization_id" uuid NOT NULL, - "outbound_message_id" uuid NOT NULL, - "bucket_id" uuid NOT NULL, - "amount" integer DEFAULT 1 NOT NULL, - "state" text DEFAULT 'reserved' NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "committed_at" timestamp with time zone, - "released_at" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "plan_send_reservations_amount_check" CHECK ("plan_send_reservations"."amount" > 0), - CONSTRAINT "plan_send_reservations_state_check" CHECK ("plan_send_reservations"."state" IN ('reserved', 'committed', 'released')) -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "plan_send_usage_buckets" ( - "id" uuid PRIMARY KEY NOT NULL, - "organization_id" uuid NOT NULL, - "bucket_month" timestamp with time zone NOT NULL, - "committed" integer DEFAULT 0 NOT NULL, - "reserved" integer DEFAULT 0 NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "plan_send_usage_buckets_count_check" CHECK ("plan_send_usage_buckets"."committed" >= 0 AND "plan_send_usage_buckets"."reserved" >= 0) + "past_due_at" timestamp with time zone, + "grace_ends_at" timestamp with time zone, + CONSTRAINT "billing_subscriptions_status_check" CHECK ("billing_subscriptions"."status" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')), + CONSTRAINT "billing_subscriptions_plan_check" CHECK ("billing_subscriptions"."plan" IN ('pro', 'business')), + CONSTRAINT "billing_subscriptions_interval_check" CHECK ("billing_subscriptions"."billing_interval" IN ('month', 'year')) ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "sending_domains" ( - "id" uuid PRIMARY KEY NOT NULL, - "domain_id" text NOT NULL, - "organization_id" uuid NOT NULL, - "domain" text NOT NULL, - "challenge_token_hash" text NOT NULL, +CREATE TABLE IF NOT EXISTS "billing_webhook_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "provider" text NOT NULL, + "provider_event_id" text NOT NULL, + "event_type" text NOT NULL, + "occurred_at" timestamp with time zone NOT NULL, + "subscription_id" text, + "checkout_attempt_id" text, + "payload_encrypted" text, + "payload_key_version" text, + "verified_key_version" text, "status" text DEFAULT 'pending' NOT NULL, - "verified_at" timestamp with time zone, - "last_checked_at" timestamp with time zone, - "next_check_at" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "sending_domains_domain_id_unique" UNIQUE("domain_id"), - CONSTRAINT "sending_domains_domain_id_check" CHECK ("sending_domains"."domain_id" ~ '^domain_'), - CONSTRAINT "sending_domains_status_check" CHECK ("sending_domains"."status" IN ('pending', 'verified', 'revoked', 'failed')) + "processing_attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "available_at" timestamp with time zone DEFAULT now() NOT NULL, + "locked_at" timestamp with time zone, + "lease_expires_at" timestamp with time zone, + "worker_id" text, + "received_at" timestamp with time zone DEFAULT now() NOT NULL, + "processed_at" timestamp with time zone, + CONSTRAINT "billing_webhook_events_status_check" CHECK ("billing_webhook_events"."status" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')) ); --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "team_sending_controls" ( +CREATE TABLE IF NOT EXISTS "billing_trial_claims" ( "id" uuid PRIMARY KEY NOT NULL, - "team_id" uuid NOT NULL, - "status" text DEFAULT 'normal' NOT NULL, - "reason_code" text, - "source" text DEFAULT 'automatic' NOT NULL, - "entered_at" timestamp with time zone, - "evaluated_at" timestamp with time zone, - "minimum_hold_until" timestamp with time zone, - "operator_user_id" text, - "operator_reason" text, - "overridden_at" timestamp with time zone, - "clean_evaluation_days" integer DEFAULT 0 NOT NULL, + "user_id" text NOT NULL, + "verified_email_fingerprint" text NOT NULL, + "fingerprint_key_version" text NOT NULL, + "trial_key" text NOT NULL, + "organization_id" uuid NOT NULL, + "checkout_attempt_id" uuid, + "status" text DEFAULT 'reserved' NOT NULL, + "expires_at" timestamp with time zone, + "redeemed_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "team_sending_controls_team_id_unique" UNIQUE("team_id"), - CONSTRAINT "team_sending_controls_status_check" CHECK ("team_sending_controls"."status" IN ('normal', 'warned', 'marketing_paused', 'all_paused')), - CONSTRAINT "team_sending_controls_source_check" CHECK ("team_sending_controls"."source" IN ('automatic', 'operator')), - CONSTRAINT "team_sending_controls_clean_days_check" CHECK ("team_sending_controls"."clean_evaluation_days" >= 0) -); ---> statement-breakpoint --- Backfill the provider-neutral Free projection for organizations created by --- older migrations. The deterministic UUID is only used for this one-time --- backfill; all new rows use the application UUIDv7 generator. -INSERT INTO "organization_plan_states" ("id", "organization_id", "plan") -SELECT md5("organizations"."id"::text || ':organization-plan-state')::uuid, - "organizations"."id", - 'free' -FROM "organizations" -WHERE NOT EXISTS ( - SELECT 1 - FROM "organization_plan_states" AS "existing" - WHERE "existing"."organization_id" = "organizations"."id" + CONSTRAINT "billing_trial_claims_status_check" CHECK ("billing_trial_claims"."status" IN ('reserved', 'redeemed', 'released')) ); --> statement-breakpoint ALTER TABLE "organizations" DROP CONSTRAINT "organizations_status_check";--> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_catalog_revision_items" ADD CONSTRAINT "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk" FOREIGN KEY ("catalog_revision_id") REFERENCES "public"."billing_catalog_revisions"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "plan_send_reservations" ADD CONSTRAINT "plan_send_reservations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_catalog_revision_items" ADD CONSTRAINT "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "plan_send_reservations" ADD CONSTRAINT "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk" FOREIGN KEY ("bucket_id") REFERENCES "public"."plan_send_usage_buckets"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "plan_send_usage_buckets" ADD CONSTRAINT "plan_send_usage_buckets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_payer_user_id_user_id_fk" FOREIGN KEY ("payer_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "sending_domains" ADD CONSTRAINT "sending_domains_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "team_sending_controls" ADD CONSTRAINT "team_sending_controls_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("billing_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "team_sending_controls" ADD CONSTRAINT "team_sending_controls_operator_user_id_user_id_fk" FOREIGN KEY ("operator_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_provider_customers" ADD CONSTRAINT "billing_provider_customers_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_catalog_revision_items" ADD CONSTRAINT "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk" FOREIGN KEY ("catalog_revision_id") REFERENCES "public"."billing_catalog_revisions"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_catalog_revision_items" ADD CONSTRAINT "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billable_entity_id_organizations_id_fk" FOREIGN KEY ("billable_entity_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk" FOREIGN KEY ("checkout_attempt_id") REFERENCES "public"."billing_checkout_attempts"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_payer_id_user_id_fk" FOREIGN KEY ("payer_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_active_subscription_id_organization_subscriptions_id_fk" FOREIGN KEY ("active_subscription_id") REFERENCES "public"."organization_subscriptions"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_checkout_attempts" ADD CONSTRAINT "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("billing_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_billable_entity_id_organizations_id_fk" FOREIGN KEY ("billable_entity_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_billing_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("billing_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."billing_subscriptions"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_billing_manager_user_id_user_id_fk" FOREIGN KEY ("billing_manager_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_payer_id_user_id_fk" FOREIGN KEY ("payer_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "organization_subscriptions" ADD CONSTRAINT "organization_subscriptions_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("current_billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "plan_send_reservations" ADD CONSTRAINT "plan_send_reservations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("target_billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "plan_send_reservations" ADD CONSTRAINT "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk" FOREIGN KEY ("bucket_id") REFERENCES "public"."plan_send_usage_buckets"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_states" ADD CONSTRAINT "billing_plan_states_billable_entity_id_organizations_id_fk" FOREIGN KEY ("billable_entity_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "plan_send_usage_buckets" ADD CONSTRAINT "plan_send_usage_buckets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_plan_states" ADD CONSTRAINT "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk" FOREIGN KEY ("active_subscription_id") REFERENCES "public"."billing_subscriptions"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "sending_domains" ADD CONSTRAINT "sending_domains_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_provider_customers" ADD CONSTRAINT "billing_provider_customers_payer_id_user_id_fk" FOREIGN KEY ("payer_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "team_sending_controls" ADD CONSTRAINT "team_sending_controls_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_reconciliation_jobs" ADD CONSTRAINT "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk" FOREIGN KEY ("checkout_attempt_id") REFERENCES "public"."billing_checkout_attempts"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "team_sending_controls" ADD CONSTRAINT "team_sending_controls_operator_user_id_user_id_fk" FOREIGN KEY ("operator_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_reconciliation_jobs" ADD CONSTRAINT "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk" FOREIGN KEY ("plan_change_attempt_id") REFERENCES "public"."billing_plan_change_attempts"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revision_items_revision_key_uidx" ON "billing_catalog_revision_items" USING btree ("catalog_revision_id","catalog_key");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revision_items_revision_price_uidx" ON "billing_catalog_revision_items" USING btree ("catalog_revision_id","billing_price_entry_id");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revisions_active_provider_uidx" ON "billing_catalog_revisions" USING btree ("checkout_provider") WHERE "billing_catalog_revisions"."status" = 'active';--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_provider_session_uidx" ON "billing_checkout_attempts" USING btree ("provider","provider_checkout_session_id") WHERE "billing_checkout_attempts"."provider_checkout_session_id" IS NOT NULL;--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_idempotency_uidx" ON "billing_checkout_attempts" USING btree ("idempotency_key");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_organization_nonterminal_uidx" ON "billing_checkout_attempts" USING btree ("organization_id") WHERE "billing_checkout_attempts"."status" IN ('creating', 'open');--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_price_entries_provider_product_uidx" ON "billing_price_entries" USING btree ("provider","provider_product_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "billing_price_entries_catalog_key_idx" ON "billing_price_entries" USING btree ("catalog_key");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_provider_user_uidx" ON "billing_provider_customers" USING btree ("provider","user_id");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_provider_customer_uidx" ON "billing_provider_customers" USING btree ("provider","provider_customer_id") WHERE "billing_provider_customers"."provider_customer_id" IS NOT NULL;--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_idempotency_uidx" ON "billing_provider_customers" USING btree ("idempotency_key");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_user_trial_uidx" ON "billing_trial_claims" USING btree ("user_id","trial_key");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_email_trial_uidx" ON "billing_trial_claims" USING btree ("verified_email_fingerprint","trial_key");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_webhook_events_provider_event_uidx" ON "billing_webhook_events" USING btree ("provider","provider_event_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "billing_webhook_events_queue_idx" ON "billing_webhook_events" USING btree ("status","available_at");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "organization_subscriptions_provider_subscription_uidx" ON "organization_subscriptions" USING btree ("provider","provider_subscription_id");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "organization_subscriptions_organization_source_uidx" ON "organization_subscriptions" USING btree ("organization_id") WHERE "organization_subscriptions"."is_entitlement_source" = true;--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "plan_send_reservations_outbound_uidx" ON "plan_send_reservations" USING btree ("outbound_message_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "plan_send_reservations_expiry_idx" ON "plan_send_reservations" USING btree ("state","expires_at");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "plan_send_usage_buckets_organization_month_uidx" ON "plan_send_usage_buckets" USING btree ("organization_id","bucket_month");--> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "sending_domains_organization_domain_uidx" ON "sending_domains" USING btree ("organization_id","domain");--> statement-breakpoint -ALTER TABLE "organizations" ADD CONSTRAINT "organizations_status_check" CHECK ("organizations"."status" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')); ---> statement-breakpoint -ALTER TABLE "billing_checkout_attempts" ADD COLUMN "pending_team_name" text; ---> statement-breakpoint -ALTER TABLE "organization_plan_states" ADD COLUMN "ramp_stage" integer DEFAULT 0 NOT NULL; ---> statement-breakpoint -ALTER TABLE "organization_plan_states" ADD COLUMN "ramp_clean_stage_days" integer DEFAULT 0 NOT NULL; ---> statement-breakpoint -ALTER TABLE "organization_plan_states" ADD COLUMN "ramp_evaluated_at" timestamp with time zone; ---> statement-breakpoint -ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_ramp_stage_check" CHECK ("organization_plan_states"."ramp_stage" BETWEEN 0 AND 3); ---> statement-breakpoint -ALTER TABLE "organization_plan_states" ADD CONSTRAINT "organization_plan_states_ramp_clean_days_check" CHECK ("organization_plan_states"."ramp_clean_stage_days" >= 0); ---> statement-breakpoint -DROP INDEX IF EXISTS "billing_trial_claims_user_trial_uidx"; ---> statement-breakpoint -DROP INDEX IF EXISTS "billing_trial_claims_email_trial_uidx"; ---> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_user_trial_uidx" ON "billing_trial_claims" USING btree ("user_id","trial_key") WHERE "billing_trial_claims"."status" <> 'released'; ---> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_email_trial_uidx" ON "billing_trial_claims" USING btree ("verified_email_fingerprint","trial_key") WHERE "billing_trial_claims"."status" <> 'released'; +DO $$ BEGIN + ALTER TABLE "billing_reconciliation_jobs" ADD CONSTRAINT "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."billing_subscriptions"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; --> statement-breakpoint -ALTER TABLE "sending_domains" ADD COLUMN "failed_check_count" integer DEFAULT 0 NOT NULL; +DO $$ BEGIN + ALTER TABLE "billing_reconciliation_jobs" ADD CONSTRAINT "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("provider_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; --> statement-breakpoint -ALTER TABLE "sending_domains" ADD COLUMN "first_failed_at" timestamp with time zone; +DO $$ BEGIN + ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_billable_entity_id_organizations_id_fk" FOREIGN KEY ("billable_entity_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; --> statement-breakpoint -ALTER TABLE "sending_domains" ADD CONSTRAINT "sending_domains_failed_check_count_check" CHECK ("sending_domains"."failed_check_count" >= 0); +DO $$ BEGIN + ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk" FOREIGN KEY ("billing_customer_id") REFERENCES "public"."billing_provider_customers"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; --> statement-breakpoint -CREATE TABLE IF NOT EXISTS "billing_plan_change_attempts" ( - "id" uuid PRIMARY KEY NOT NULL, - "change_id" text NOT NULL, - "organization_id" uuid NOT NULL, - "subscription_id" uuid NOT NULL, - "actor_user_id" text NOT NULL, - "provider" text NOT NULL, - "idempotency_key" text NOT NULL, - "current_catalog_revision" integer NOT NULL, - "current_billing_price_entry_id" uuid NOT NULL, - "current_plan" text NOT NULL, - "current_interval" text NOT NULL, - "target_catalog_revision" integer NOT NULL, - "target_billing_price_entry_id" uuid NOT NULL, - "target_plan" text NOT NULL, - "target_interval" text NOT NULL, - "effective_at" text NOT NULL, - "proration_mode" text NOT NULL, - "provider_payment_id" text, - "payment_url_encrypted" text, - "status" text DEFAULT 'creating' NOT NULL, - "last_error" text, - "requested_at" timestamp with time zone DEFAULT now() NOT NULL, - "completed_at" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "billing_plan_change_attempts_change_id_unique" UNIQUE("change_id"), - CONSTRAINT "billing_plan_change_attempts_status_check" CHECK ("billing_plan_change_attempts"."status" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')), - CONSTRAINT "billing_plan_change_attempts_effective_at_check" CHECK ("billing_plan_change_attempts"."effective_at" IN ('immediately', 'next_billing_date')), - CONSTRAINT "billing_plan_change_attempts_proration_mode_check" CHECK ("billing_plan_change_attempts"."proration_mode" IN ('prorated_immediately', 'do_not_bill')), - CONSTRAINT "billing_plan_change_attempts_current_plan_check" CHECK ("billing_plan_change_attempts"."current_plan" IN ('pro', 'business')), - CONSTRAINT "billing_plan_change_attempts_target_plan_check" CHECK ("billing_plan_change_attempts"."target_plan" IN ('pro', 'business')), - CONSTRAINT "billing_plan_change_attempts_current_interval_check" CHECK ("billing_plan_change_attempts"."current_interval" IN ('month', 'year')), - CONSTRAINT "billing_plan_change_attempts_target_interval_check" CHECK ("billing_plan_change_attempts"."target_interval" IN ('month', 'year')), - CONSTRAINT "billing_plan_change_attempts_revision_check" CHECK ("billing_plan_change_attempts"."current_catalog_revision" > 0 AND "billing_plan_change_attempts"."target_catalog_revision" > 0) -); +DO $$ BEGIN + ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_payer_id_user_id_fk" FOREIGN KEY ("payer_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk" FOREIGN KEY ("origin_checkout_attempt_id") REFERENCES "public"."billing_checkout_attempts"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_subscription_id_organization_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."organization_subscriptions"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("current_billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN - ALTER TABLE "billing_plan_change_attempts" ADD CONSTRAINT "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk" FOREIGN KEY ("target_billing_price_entry_id") REFERENCES "public"."billing_price_entries"("id") ON DELETE restrict ON UPDATE no action; + ALTER TABLE "billing_trial_claims" ADD CONSTRAINT "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk" FOREIGN KEY ("checkout_attempt_id") REFERENCES "public"."billing_checkout_attempts"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_plan_change_attempts_idempotency_uidx" ON "billing_plan_change_attempts" USING btree ("idempotency_key"); +CREATE UNIQUE INDEX IF NOT EXISTS "plan_send_reservations_outbound_uidx" ON "plan_send_reservations" USING btree ("outbound_message_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plan_send_reservations_expiry_idx" ON "plan_send_reservations" USING btree ("state","expires_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plan_send_usage_buckets_organization_month_uidx" ON "plan_send_usage_buckets" USING btree ("organization_id","bucket_month");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "sending_domains_organization_domain_uidx" ON "sending_domains" USING btree ("organization_id","domain");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revision_items_revision_key_uidx" ON "billing_catalog_revision_items" USING btree ("catalog_revision_id","offer_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revision_items_revision_price_uidx" ON "billing_catalog_revision_items" USING btree ("catalog_revision_id","billing_price_entry_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_catalog_revisions_active_provider_uidx" ON "billing_catalog_revisions" USING btree ("checkout_provider") WHERE "billing_catalog_revisions"."status" = 'active';--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_provider_session_uidx" ON "billing_checkout_attempts" USING btree ("provider","provider_checkout_session_id") WHERE "billing_checkout_attempts"."provider_checkout_session_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_idempotency_uidx" ON "billing_checkout_attempts" USING btree ("idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_checkout_attempts_entity_nonterminal_uidx" ON "billing_checkout_attempts" USING btree ("billable_entity_id") WHERE "billing_checkout_attempts"."status" IN ('creating', 'open');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_plan_change_attempts_idempotency_uidx" ON "billing_plan_change_attempts" USING btree ("idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_plan_change_attempts_entity_nonterminal_uidx" ON "billing_plan_change_attempts" USING btree ("billable_entity_id") WHERE "billing_plan_change_attempts"."status" IN ('creating', 'pending');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_price_entries_provider_product_uidx" ON "billing_price_entries" USING btree ("provider","provider_product_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "billing_price_entries_offer_key_idx" ON "billing_price_entries" USING btree ("offer_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_provider_payer_uidx" ON "billing_provider_customers" USING btree ("provider","payer_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_provider_customer_uidx" ON "billing_provider_customers" USING btree ("provider","provider_customer_id") WHERE "billing_provider_customers"."provider_customer_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_provider_customers_idempotency_uidx" ON "billing_provider_customers" USING btree ("idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_reconciliation_jobs_live_checkout_uidx" ON "billing_reconciliation_jobs" USING btree ("checkout_attempt_id") WHERE "billing_reconciliation_jobs"."checkout_attempt_id" IS NOT NULL AND "billing_reconciliation_jobs"."status" IN ('pending', 'processing', 'failed');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_reconciliation_jobs_live_plan_change_uidx" ON "billing_reconciliation_jobs" USING btree ("plan_change_attempt_id") WHERE "billing_reconciliation_jobs"."plan_change_attempt_id" IS NOT NULL AND "billing_reconciliation_jobs"."status" IN ('pending', 'processing', 'failed');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_reconciliation_jobs_live_subscription_uidx" ON "billing_reconciliation_jobs" USING btree ("subscription_id") WHERE "billing_reconciliation_jobs"."subscription_id" IS NOT NULL AND "billing_reconciliation_jobs"."status" IN ('pending', 'processing', 'failed');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_reconciliation_jobs_live_customer_uidx" ON "billing_reconciliation_jobs" USING btree ("provider_customer_id") WHERE "billing_reconciliation_jobs"."provider_customer_id" IS NOT NULL AND "billing_reconciliation_jobs"."status" IN ('pending', 'processing', 'failed');--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_subscriptions_provider_subscription_uidx" ON "billing_subscriptions" USING btree ("provider","provider_subscription_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_subscriptions_entity_source_uidx" ON "billing_subscriptions" USING btree ("billable_entity_id") WHERE "billing_subscriptions"."is_entitlement_source" = true;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_webhook_events_provider_event_uidx" ON "billing_webhook_events" USING btree ("provider","provider_event_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "billing_webhook_events_queue_idx" ON "billing_webhook_events" USING btree ("status","available_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_user_trial_uidx" ON "billing_trial_claims" USING btree ("user_id","trial_key") WHERE "billing_trial_claims"."status" <> 'released';--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "billing_trial_claims_email_trial_uidx" ON "billing_trial_claims" USING btree ("verified_email_fingerprint","trial_key") WHERE "billing_trial_claims"."status" <> 'released';--> statement-breakpoint +ALTER TABLE "organizations" ADD CONSTRAINT "organizations_status_check" CHECK ("organizations"."status" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')); --> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "billing_plan_change_attempts_organization_nonterminal_uidx" ON "billing_plan_change_attempts" USING btree ("organization_id") WHERE "billing_plan_change_attempts"."status" IN ('creating', 'pending'); +-- Application backfill (not emitted by drizzle-kit): existing organisations +-- get a Free plan-state row. New rows use the application UUID generator. +INSERT INTO "billing_plan_states" ("id", "billable_entity_id", "plan", "ramp_stage", "ramp_clean_stage_days", "projection_version") +SELECT md5("organizations"."id"::text || ':billing-plan-state')::uuid, + "organizations"."id", + 'free', + 0, + 0, + 0 +FROM "organizations" +WHERE NOT EXISTS ( + SELECT 1 + FROM "billing_plan_states" AS "existing" + WHERE "existing"."billable_entity_id" = "organizations"."id" +); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json index 74b16da..8a16636 100644 --- a/apps/api/drizzle/meta/0005_snapshot.json +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a594d9b3-210f-4f32-8659-fc030231be4f", + "id": "a438fbda-6466-4ce2-9b07-01d733f4c192", "prevId": "8853889e-feb0-41bf-a7ab-bc9b4d0c5fcc", "version": "7", "dialect": "postgresql", @@ -148,8 +148,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.billing_catalog_revision_items": { - "name": "billing_catalog_revision_items", + "public.contact_custom_field_values": { + "name": "contact_custom_field_values", "schema": "", "columns": { "id": { @@ -158,86 +158,223 @@ "primaryKey": true, "notNull": true }, - "catalog_revision_id": { - "name": "catalog_revision_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "catalog_key": { - "name": "catalog_key", + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", "type": "text", "primaryKey": false, "notNull": true }, - "billing_price_entry_id": { - "name": "billing_price_entry_id", - "type": "uuid", + "value_type": { + "name": "value_type", + "type": "text", "primaryKey": false, "notNull": true + }, + "value_text": { + "name": "value_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_number": { + "name": "value_number", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "value_boolean": { + "name": "value_boolean", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "value_date": { + "name": "value_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" } }, "indexes": { - "billing_catalog_revision_items_revision_key_uidx": { - "name": "billing_catalog_revision_items_revision_key_uidx", + "contact_custom_field_values_contact_key_idx": { + "name": "contact_custom_field_values_contact_key_idx", "columns": [ { - "expression": "catalog_revision_id", + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "catalog_key", + "expression": "key", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, - "billing_catalog_revision_items_revision_price_uidx": { - "name": "billing_catalog_revision_items_revision_price_uidx", + "contact_custom_field_values_text_lookup_idx": { + "name": "contact_custom_field_values_text_lookup_idx", "columns": [ { - "expression": "catalog_revision_id", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "billing_price_entry_id", + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_text", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_number_lookup_idx": { + "name": "contact_custom_field_values_number_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_boolean_lookup_idx": { + "name": "contact_custom_field_values_boolean_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_boolean", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_date_lookup_idx": { + "name": "contact_custom_field_values_date_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk": { - "name": "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk", - "tableFrom": "billing_catalog_revision_items", - "tableTo": "billing_catalog_revisions", - "columnsFrom": ["catalog_revision_id"], + "contact_custom_field_values_team_id_teams_id_fk": { + "name": "contact_custom_field_values_team_id_teams_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk": { - "name": "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk", - "tableFrom": "billing_catalog_revision_items", - "tableTo": "billing_price_entries", - "columnsFrom": ["billing_price_entry_id"], + "contact_custom_field_values_contact_id_contacts_id_fk": { + "name": "contact_custom_field_values_contact_id_contacts_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -247,8 +384,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.billing_catalog_revisions": { - "name": "billing_catalog_revisions", + "public.contacts": { + "name": "contacts", "schema": "", "columns": { "id": { @@ -257,100 +394,130 @@ "primaryKey": true, "notNull": true }, - "revision": { - "name": "revision", - "type": "integer", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "checkout_provider": { - "name": "checkout_provider", + "contact_id": { + "name": "contact_id", "type": "text", "primaryKey": false, "notNull": true }, - "status": { - "name": "status", + "email": { + "name": "email", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'pending_verification'" + "notNull": true }, - "verified_at": { - "name": "verified_at", - "type": "timestamp with time zone", + "name": { + "name": "name", + "type": "text", "primaryKey": false, "notNull": false }, - "activated_at": { - "name": "activated_at", - "type": "timestamp with time zone", + "subscribed": { + "name": "subscribed", + "type": "boolean", "primaryKey": false, - "notNull": false + "notNull": true, + "default": true }, - "retired_at": { - "name": "retired_at", - "type": "timestamp with time zone", + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "text", + "primaryKey": false, + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, "indexes": { - "billing_catalog_revisions_active_provider_uidx": { - "name": "billing_catalog_revisions_active_provider_uidx", + "contacts_team_id_email_idx": { + "name": "contacts_team_id_email_idx", "columns": [ { - "expression": "checkout_provider", + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, - "where": "\"billing_catalog_revisions\".\"status\" = 'active'", "concurrently": false, "method": "btree", "with": {} } }, - "foreignKeys": {}, + "foreignKeys": { + "contacts_team_id_teams_id_fk": { + "name": "contacts_team_id_teams_id_fk", + "tableFrom": "contacts", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "billing_catalog_revisions_revision_unique": { - "name": "billing_catalog_revisions_revision_unique", + "contacts_contact_id_unique": { + "name": "contacts_contact_id_unique", "nullsNotDistinct": false, - "columns": ["revision"] + "columns": ["contact_id"] + }, + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": ["unsubscribe_token"] } }, "policies": {}, "checkConstraints": { - "billing_catalog_revisions_status_check": { - "name": "billing_catalog_revisions_status_check", - "value": "\"billing_catalog_revisions\".\"status\" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')" - }, - "billing_catalog_revisions_revision_check": { - "name": "billing_catalog_revisions_revision_check", - "value": "\"billing_catalog_revisions\".\"revision\" > 0" + "contacts_contact_id_check": { + "name": "contacts_contact_id_check", + "value": "\"contacts\".\"contact_id\" ~ '^cnt_'" } }, "isRLSEnabled": false }, - "public.billing_checkout_attempts": { - "name": "billing_checkout_attempts", + "public.email_deliveries": { + "name": "email_deliveries", "schema": "", "columns": { "id": { @@ -359,258 +526,325 @@ "primaryKey": true, "notNull": true }, - "attempt_id": { - "name": "attempt_id", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "organization_id": { - "name": "organization_id", + "sequence_id": { + "name": "sequence_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "payer_user_id": { - "name": "payer_user_id", - "type": "text", + "contact_id": { + "name": "contact_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", - "type": "text", + "email_id": { + "name": "email_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "catalog_revision": { - "name": "catalog_revision", - "type": "integer", + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_deliveries_team_id_teams_id_fk": { + "name": "email_deliveries_team_id_teams_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_sequence_id_sequences_id_fk": { + "name": "email_deliveries_sequence_id_sequences_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_contact_id_contacts_id_fk": { + "name": "email_deliveries_contact_id_contacts_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_email_id_sequence_emails_id_fk": { + "name": "email_deliveries_email_id_sequence_emails_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequence_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_delivery_events": { + "name": "email_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, "notNull": true }, - "catalog_key": { - "name": "catalog_key", + "event_id": { + "name": "event_id", "type": "text", "primaryKey": false, "notNull": true }, - "requested_plan": { - "name": "requested_plan", - "type": "text", + "receipt_id": { + "name": "receipt_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "requested_interval": { - "name": "requested_interval", - "type": "text", + "connection_id": { + "name": "connection_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "pending_team_name": { - "name": "pending_team_name", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "billing_price_entry_id": { - "name": "billing_price_entry_id", + "outbound_message_id": { + "name": "outbound_message_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, - "quoted_amount_minor": { - "name": "quoted_amount_minor", - "type": "integer", + "provider": { + "name": "provider", + "type": "text", "primaryKey": false, "notNull": true }, - "quoted_currency": { - "name": "quoted_currency", + "provider_event_key": { + "name": "provider_event_key", "type": "text", "primaryKey": false, "notNull": true }, - "billing_customer_id": { - "name": "billing_customer_id", - "type": "uuid", + "provider_message_id": { + "name": "provider_message_id", + "type": "text", "primaryKey": false, "notNull": false }, - "provider_checkout_session_id": { - "name": "provider_checkout_session_id", + "recipient_email": { + "name": "recipient_email", "type": "text", "primaryKey": false, "notNull": false }, - "checkout_url_encrypted": { - "name": "checkout_url_encrypted", + "normalized_recipient": { + "name": "normalized_recipient", "type": "text", "primaryKey": false, "notNull": false }, - "idempotency_key": { - "name": "idempotency_key", + "event_type": { + "name": "event_type", "type": "text", "primaryKey": false, "notNull": true }, - "status": { - "name": "status", + "bounce_class": { + "name": "bounce_class", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'creating'" + "notNull": false }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "smtp_code": { + "name": "smtp_code", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": false }, - "last_error": { - "name": "last_error", + "enhanced_status_code": { + "name": "enhanced_status_code", "type": "text", "primaryKey": false, "notNull": false }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", + "reason": { + "name": "reason", + "type": "text", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", + "remote_mta": { + "name": "remote_mta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": true }, - "updated_at": { - "name": "updated_at", + "received_at": { + "name": "received_at", "type": "timestamp with time zone", "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, "notNull": true, - "default": "now()" + "default": "'{}'::jsonb" } }, "indexes": { - "billing_checkout_attempts_provider_session_uidx": { - "name": "billing_checkout_attempts_provider_session_uidx", + "email_delivery_events_connection_id_provider_event_key_idx": { + "name": "email_delivery_events_connection_id_provider_event_key_idx", "columns": [ { - "expression": "provider", + "expression": "connection_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "provider_checkout_session_id", + "expression": "provider_event_key", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, - "where": "\"billing_checkout_attempts\".\"provider_checkout_session_id\" IS NOT NULL", "concurrently": false, "method": "btree", "with": {} }, - "billing_checkout_attempts_idempotency_uidx": { - "name": "billing_checkout_attempts_idempotency_uidx", + "email_delivery_events_team_id_occurred_at_idx": { + "name": "email_delivery_events_team_id_occurred_at_idx", "columns": [ { - "expression": "idempotency_key", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, "method": "btree", "with": {} }, - "billing_checkout_attempts_organization_nonterminal_uidx": { - "name": "billing_checkout_attempts_organization_nonterminal_uidx", + "email_delivery_events_outbound_message_id_idx": { + "name": "email_delivery_events_outbound_message_id_idx", "columns": [ { - "expression": "organization_id", + "expression": "outbound_message_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, - "where": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open')", + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "billing_checkout_attempts_organization_id_organizations_id_fk": { - "name": "billing_checkout_attempts_organization_id_organizations_id_fk", - "tableFrom": "billing_checkout_attempts", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk": { + "name": "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_webhook_receipts", + "columnsFrom": ["receipt_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "billing_checkout_attempts_payer_user_id_user_id_fk": { - "name": "billing_checkout_attempts_payer_user_id_user_id_fk", - "tableFrom": "billing_checkout_attempts", - "tableTo": "user", - "columnsFrom": ["payer_user_id"], + "email_delivery_events_connection_id_esp_feedback_connections_id_fk": { + "name": "email_delivery_events_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["connection_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk": { - "name": "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk", - "tableFrom": "billing_checkout_attempts", - "tableTo": "billing_price_entries", - "columnsFrom": ["billing_price_entry_id"], + "email_delivery_events_team_id_teams_id_fk": { + "name": "email_delivery_events_team_id_teams_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk": { - "name": "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk", - "tableFrom": "billing_checkout_attempts", - "tableTo": "billing_provider_customers", - "columnsFrom": ["billing_customer_id"], + "email_delivery_events_outbound_message_id_outbound_messages_id_fk": { + "name": "email_delivery_events_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "set null", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "billing_checkout_attempts_attempt_id_unique": { - "name": "billing_checkout_attempts_attempt_id_unique", + "email_delivery_events_event_id_unique": { + "name": "email_delivery_events_event_id_unique", "nullsNotDistinct": false, - "columns": ["attempt_id"] + "columns": ["event_id"] } }, "policies": {}, "checkConstraints": { - "billing_checkout_attempts_status_check": { - "name": "billing_checkout_attempts_status_check", - "value": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')" - }, - "billing_checkout_attempts_amount_check": { - "name": "billing_checkout_attempts_amount_check", - "value": "\"billing_checkout_attempts\".\"quoted_amount_minor\" > 0" + "email_delivery_events_event_id_check": { + "name": "email_delivery_events_event_id_check", + "value": "\"email_delivery_events\".\"event_id\" ~ '^evt_'" } }, "isRLSEnabled": false }, - "public.billing_plan_change_attempts": { - "name": "billing_plan_change_attempts", + "public.email_events": { + "name": "email_events", "schema": "", "columns": { "id": { @@ -619,137 +853,162 @@ "primaryKey": true, "notNull": true }, - "change_id": { - "name": "change_id", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "organization_id": { - "name": "organization_id", + "sequence_id": { + "name": "sequence_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "subscription_id": { - "name": "subscription_id", + "contact_id": { + "name": "contact_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "actor_user_id": { - "name": "actor_user_id", - "type": "text", + "email_id": { + "name": "email_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", + "action": { + "name": "action", "type": "text", "primaryKey": false, "notNull": true }, - "idempotency_key": { - "name": "idempotency_key", + "link": { + "name": "link", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "current_catalog_revision": { - "name": "current_catalog_revision", + "link_index": { + "name": "link_index", "type": "integer", "primaryKey": false, - "notNull": true - }, - "current_billing_price_entry_id": { - "name": "current_billing_price_entry_id", - "type": "uuid", - "primaryKey": false, - "notNull": true + "notNull": false }, - "current_plan": { - "name": "current_plan", + "bounce_type": { + "name": "bounce_type", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "current_interval": { - "name": "current_interval", + "bounce_reason": { + "name": "bounce_reason", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "target_catalog_revision": { - "name": "target_catalog_revision", - "type": "integer", + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_events_team_id_teams_id_fk": { + "name": "email_events_team_id_teams_id_fk", + "tableFrom": "email_events", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" }, - "target_billing_price_entry_id": { - "name": "target_billing_price_entry_id", - "type": "uuid", - "primaryKey": false, - "notNull": true + "email_events_sequence_id_sequences_id_fk": { + "name": "email_events_sequence_id_sequences_id_fk", + "tableFrom": "email_events", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" }, - "target_plan": { - "name": "target_plan", - "type": "text", - "primaryKey": false, - "notNull": true + "email_events_contact_id_contacts_id_fk": { + "name": "email_events_contact_id_contacts_id_fk", + "tableFrom": "email_events", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" }, - "target_interval": { - "name": "target_interval", - "type": "text", - "primaryKey": false, + "email_events_email_id_sequence_emails_id_fk": { + "name": "email_events_email_id_sequence_emails_id_fk", + "tableFrom": "email_events", + "tableTo": "sequence_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppression_actions": { + "name": "email_suppression_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, "notNull": true }, - "effective_at": { - "name": "effective_at", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "proration_mode": { - "name": "proration_mode", - "type": "text", + "suppression_id": { + "name": "suppression_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "provider_payment_id": { - "name": "provider_payment_id", - "type": "text", + "source_event_id": { + "name": "source_event_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "payment_url_encrypted": { - "name": "payment_url_encrypted", + "action": { + "name": "action", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "status": { - "name": "status", + "actor_type": { + "name": "actor_type", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'creating'" + "notNull": true }, - "last_error": { - "name": "last_error", + "actor_user_id": { + "name": "actor_user_id", "type": "text", "primaryKey": false, "notNull": false }, - "requested_at": { - "name": "requested_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", + "explanation": { + "name": "explanation", + "type": "text", "primaryKey": false, "notNull": false }, @@ -759,142 +1018,77 @@ "primaryKey": false, "notNull": true, "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" } }, "indexes": { - "billing_plan_change_attempts_idempotency_uidx": { - "name": "billing_plan_change_attempts_idempotency_uidx", + "email_suppression_actions_suppression_id_created_at_idx": { + "name": "email_suppression_actions_suppression_id_created_at_idx", "columns": [ { - "expression": "idempotency_key", + "expression": "suppression_id", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "billing_plan_change_attempts_organization_nonterminal_uidx": { - "name": "billing_plan_change_attempts_organization_nonterminal_uidx", - "columns": [ + }, { - "expression": "organization_id", + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, - "where": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending')", + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "billing_plan_change_attempts_organization_id_organizations_id_fk": { - "name": "billing_plan_change_attempts_organization_id_organizations_id_fk", - "tableFrom": "billing_plan_change_attempts", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "billing_plan_change_attempts_subscription_id_organization_subscriptions_id_fk": { - "name": "billing_plan_change_attempts_subscription_id_organization_subscriptions_id_fk", - "tableFrom": "billing_plan_change_attempts", - "tableTo": "organization_subscriptions", - "columnsFrom": ["subscription_id"], + "email_suppression_actions_team_id_teams_id_fk": { + "name": "email_suppression_actions_team_id_teams_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "billing_plan_change_attempts_actor_user_id_user_id_fk": { - "name": "billing_plan_change_attempts_actor_user_id_user_id_fk", - "tableFrom": "billing_plan_change_attempts", - "tableTo": "user", - "columnsFrom": ["actor_user_id"], + "email_suppression_actions_suppression_id_email_suppressions_id_fk": { + "name": "email_suppression_actions_suppression_id_email_suppressions_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_suppressions", + "columnsFrom": ["suppression_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk": { - "name": "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk", - "tableFrom": "billing_plan_change_attempts", - "tableTo": "billing_price_entries", - "columnsFrom": ["current_billing_price_entry_id"], + "email_suppression_actions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppression_actions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_delivery_events", + "columnsFrom": ["source_event_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "set null", "onUpdate": "no action" }, - "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk": { - "name": "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk", - "tableFrom": "billing_plan_change_attempts", - "tableTo": "billing_price_entries", - "columnsFrom": ["target_billing_price_entry_id"], + "email_suppression_actions_actor_user_id_user_id_fk": { + "name": "email_suppression_actions_actor_user_id_user_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "user", + "columnsFrom": ["actor_user_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "set null", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "billing_plan_change_attempts_change_id_unique": { - "name": "billing_plan_change_attempts_change_id_unique", - "nullsNotDistinct": false, - "columns": ["change_id"] - } - }, + "uniqueConstraints": {}, "policies": {}, - "checkConstraints": { - "billing_plan_change_attempts_status_check": { - "name": "billing_plan_change_attempts_status_check", - "value": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')" - }, - "billing_plan_change_attempts_effective_at_check": { - "name": "billing_plan_change_attempts_effective_at_check", - "value": "\"billing_plan_change_attempts\".\"effective_at\" IN ('immediately', 'next_billing_date')" - }, - "billing_plan_change_attempts_proration_mode_check": { - "name": "billing_plan_change_attempts_proration_mode_check", - "value": "\"billing_plan_change_attempts\".\"proration_mode\" IN ('prorated_immediately', 'do_not_bill')" - }, - "billing_plan_change_attempts_current_plan_check": { - "name": "billing_plan_change_attempts_current_plan_check", - "value": "\"billing_plan_change_attempts\".\"current_plan\" IN ('pro', 'business')" - }, - "billing_plan_change_attempts_target_plan_check": { - "name": "billing_plan_change_attempts_target_plan_check", - "value": "\"billing_plan_change_attempts\".\"target_plan\" IN ('pro', 'business')" - }, - "billing_plan_change_attempts_current_interval_check": { - "name": "billing_plan_change_attempts_current_interval_check", - "value": "\"billing_plan_change_attempts\".\"current_interval\" IN ('month', 'year')" - }, - "billing_plan_change_attempts_target_interval_check": { - "name": "billing_plan_change_attempts_target_interval_check", - "value": "\"billing_plan_change_attempts\".\"target_interval\" IN ('month', 'year')" - }, - "billing_plan_change_attempts_revision_check": { - "name": "billing_plan_change_attempts_revision_check", - "value": "\"billing_plan_change_attempts\".\"current_catalog_revision\" > 0 AND \"billing_plan_change_attempts\".\"target_catalog_revision\" > 0" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.billing_price_entries": { - "name": "billing_price_entries", + "public.email_suppressions": { + "name": "email_suppressions", "schema": "", "columns": { "id": { @@ -903,81 +1097,120 @@ "primaryKey": true, "notNull": true }, - "catalog_key": { - "name": "catalog_key", + "suppression_id": { + "name": "suppression_id", "type": "text", "primaryKey": false, "notNull": true }, - "plan": { - "name": "plan", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "billing_interval": { - "name": "billing_interval", + "recipient_email": { + "name": "recipient_email", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "currency": { - "name": "currency", + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_hash": { + "name": "recipient_hash", "type": "text", "primaryKey": false, "notNull": true }, - "amount_minor": { - "name": "amount_minor", + "hash_key_version": { + "name": "hash_key_version", "type": "integer", "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", + "reason": { + "name": "reason", "type": "text", "primaryKey": false, "notNull": true }, - "provider_product_id": { - "name": "provider_product_id", - "type": "text", + "source_event_id": { + "name": "source_event_id", + "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, - "verified_at": { - "name": "verified_at", + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "first_suppressed_at": { + "name": "first_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_suppressed_at": { + "name": "last_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, "indexes": { - "billing_price_entries_provider_product_uidx": { - "name": "billing_price_entries_provider_product_uidx", + "email_suppressions_team_id_recipient_hash_idx": { + "name": "email_suppressions_team_id_recipient_hash_idx", "columns": [ { - "expression": "provider", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "provider_product_id", + "expression": "recipient_hash", "isExpression": false, "asc": true, "nulls": "last" @@ -988,11 +1221,17 @@ "method": "btree", "with": {} }, - "billing_price_entries_catalog_key_idx": { - "name": "billing_price_entries_catalog_key_idx", + "email_suppressions_team_id_active_idx": { + "name": "email_suppressions_team_id_active_idx", "columns": [ { - "expression": "catalog_key", + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", "isExpression": false, "asc": true, "nulls": "last" @@ -1004,32 +1243,54 @@ "with": {} } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "billing_price_entries_amount_check": { - "name": "billing_price_entries_amount_check", - "value": "\"billing_price_entries\".\"amount_minor\" > 0" - }, - "billing_price_entries_currency_check": { - "name": "billing_price_entries_currency_check", - "value": "\"billing_price_entries\".\"currency\" ~ '^[A-Z]{3}$'" + "foreignKeys": { + "email_suppressions_team_id_teams_id_fk": { + "name": "email_suppressions_team_id_teams_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" }, - "billing_price_entries_plan_check": { - "name": "billing_price_entries_plan_check", - "value": "\"billing_price_entries\".\"plan\" IN ('pro', 'business')" + "email_suppressions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppressions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "email_delivery_events", + "columnsFrom": ["source_event_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" }, - "billing_price_entries_interval_check": { - "name": "billing_price_entries_interval_check", - "value": "\"billing_price_entries\".\"billing_interval\" IN ('month', 'year')" + "email_suppressions_released_by_user_id_fk": { + "name": "email_suppressions_released_by_user_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "user", + "columnsFrom": ["released_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, - "isRLSEnabled": false + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_suppressions_suppression_id_unique": { + "name": "email_suppressions_suppression_id_unique", + "nullsNotDistinct": false, + "columns": ["suppression_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_suppressions_suppression_id_check": { + "name": "email_suppressions_suppression_id_check", + "value": "\"email_suppressions\".\"suppression_id\" ~ '^sup_'" + } + }, + "isRLSEnabled": false }, - "public.billing_provider_customers": { - "name": "billing_provider_customers", + "public.email_templates": { + "name": "email_templates", "schema": "", "columns": { "id": { @@ -1038,107 +1299,64 @@ "primaryKey": true, "notNull": true }, - "provider": { - "name": "provider", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "user_id": { - "name": "user_id", + "template_id": { + "name": "template_id", "type": "text", "primaryKey": false, "notNull": true }, - "provider_customer_id": { - "name": "provider_customer_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "idempotency_key": { - "name": "idempotency_key", + "title": { + "name": "title", "type": "text", "primaryKey": false, "notNull": true }, - "status": { - "name": "status", + "purpose": { + "name": "purpose", "type": "text", "primaryKey": false, "notNull": true, - "default": "'creating'" + "default": "'marketing'" }, - "last_error": { - "name": "last_error", - "type": "text", + "content": { + "name": "content", + "type": "jsonb", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, "indexes": { - "billing_provider_customers_provider_user_uidx": { - "name": "billing_provider_customers_provider_user_uidx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "billing_provider_customers_provider_customer_uidx": { - "name": "billing_provider_customers_provider_customer_uidx", + "email_templates_team_id_title_idx": { + "name": "email_templates_team_id_title_idx", "columns": [ { - "expression": "provider", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "provider_customer_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"billing_provider_customers\".\"provider_customer_id\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "billing_provider_customers_idempotency_uidx": { - "name": "billing_provider_customers_idempotency_uidx", - "columns": [ - { - "expression": "idempotency_key", + "expression": "title", "isExpression": false, "asc": true, "nulls": "last" @@ -1151,29 +1369,39 @@ } }, "foreignKeys": { - "billing_provider_customers_user_id_user_id_fk": { - "name": "billing_provider_customers_user_id_user_id_fk", - "tableFrom": "billing_provider_customers", - "tableTo": "user", - "columnsFrom": ["user_id"], + "email_templates_team_id_teams_id_fk": { + "name": "email_templates_team_id_teams_id_fk", + "tableFrom": "email_templates", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "email_templates_template_id_unique": { + "name": "email_templates_template_id_unique", + "nullsNotDistinct": false, + "columns": ["template_id"] + } + }, "policies": {}, "checkConstraints": { - "billing_provider_customers_status_check": { - "name": "billing_provider_customers_status_check", - "value": "\"billing_provider_customers\".\"status\" IN ('creating', 'active', 'conflicted')" + "email_templates_template_id_check": { + "name": "email_templates_template_id_check", + "value": "\"email_templates\".\"template_id\" ~ '^tpl_'" + }, + "email_templates_purpose_check": { + "name": "email_templates_purpose_check", + "value": "\"email_templates\".\"purpose\" in ('marketing', 'transactional')" } }, "isRLSEnabled": false }, - "public.billing_trial_claims": { - "name": "billing_trial_claims", + "public.esp_config_team_grants": { + "name": "esp_config_team_grants", "schema": "", "columns": { "id": { @@ -1182,58 +1410,76 @@ "primaryKey": true, "notNull": true }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "verified_email_fingerprint": { - "name": "verified_email_fingerprint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "fingerprint_key_version": { - "name": "fingerprint_key_version", + "grant_id": { + "name": "grant_id", "type": "text", "primaryKey": false, "notNull": true }, - "trial_key": { - "name": "trial_key", - "type": "text", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "organization_id": { - "name": "organization_id", + "esp_config_id": { + "name": "esp_config_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "checkout_attempt_id": { - "name": "checkout_attempt_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, - "default": "'reserved'" + "default": "'active'" }, - "expires_at": { - "name": "expires_at", + "drain_until": { + "name": "drain_until", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "redeemed_at": { - "name": "redeemed_at", - "type": "timestamp with time zone", + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "daily_limit": { + "name": "daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", "primaryKey": false, "notNull": false }, @@ -1253,93 +1499,84 @@ } }, "indexes": { - "billing_trial_claims_user_trial_uidx": { - "name": "billing_trial_claims_user_trial_uidx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "trial_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"billing_trial_claims\".\"status\" <> 'released'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "billing_trial_claims_email_trial_uidx": { - "name": "billing_trial_claims_email_trial_uidx", + "esp_config_team_grants_non_revoked_team_idx": { + "name": "esp_config_team_grants_non_revoked_team_idx", "columns": [ { - "expression": "verified_email_fingerprint", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "trial_key", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, - "where": "\"billing_trial_claims\".\"status\" <> 'released'", + "where": "\"esp_config_team_grants\".\"status\" <> 'revoked'", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "billing_trial_claims_user_id_user_id_fk": { - "name": "billing_trial_claims_user_id_user_id_fk", - "tableFrom": "billing_trial_claims", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "esp_config_team_grants_team_organization_fk": { + "name": "esp_config_team_grants_team_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "teams", + "columnsFrom": ["team_id", "organization_id"], + "columnsTo": ["id", "organization_id"], "onDelete": "restrict", "onUpdate": "no action" }, - "billing_trial_claims_organization_id_organizations_id_fk": { - "name": "billing_trial_claims_organization_id_organizations_id_fk", - "tableFrom": "billing_trial_claims", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk": { - "name": "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk", - "tableFrom": "billing_trial_claims", - "tableTo": "billing_checkout_attempts", - "columnsFrom": ["checkout_attempt_id"], - "columnsTo": ["id"], + "esp_config_team_grants_esp_organization_fk": { + "name": "esp_config_team_grants_esp_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id", "organization_id"], + "columnsTo": ["id", "organization_id"], "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "esp_config_team_grants_grant_id_unique": { + "name": "esp_config_team_grants_grant_id_unique", + "nullsNotDistinct": false, + "columns": ["grant_id"] + }, + "esp_config_team_grants_id_organization_id_unique": { + "name": "esp_config_team_grants_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] + }, + "esp_config_team_grants_id_team_esp_unique": { + "name": "esp_config_team_grants_id_team_esp_unique", + "nullsNotDistinct": false, + "columns": ["id", "team_id", "esp_config_id"] + } + }, "policies": {}, "checkConstraints": { - "billing_trial_claims_status_check": { - "name": "billing_trial_claims_status_check", - "value": "\"billing_trial_claims\".\"status\" IN ('reserved', 'redeemed', 'released')" + "esp_config_team_grants_public_id_check": { + "name": "esp_config_team_grants_public_id_check", + "value": "\"esp_config_team_grants\".\"grant_id\" ~ '^egr_'" + }, + "esp_config_team_grants_status_check": { + "name": "esp_config_team_grants_status_check", + "value": "\"esp_config_team_grants\".\"status\" IN ('active', 'draining', 'suspended', 'revoked')" + }, + "esp_config_team_grants_limit_check": { + "name": "esp_config_team_grants_limit_check", + "value": "(\"esp_config_team_grants\".\"daily_limit\" IS NULL OR \"esp_config_team_grants\".\"daily_limit\" >= 0)\n AND (\"esp_config_team_grants\".\"monthly_limit\" IS NULL OR \"esp_config_team_grants\".\"monthly_limit\" >= 0)" + }, + "esp_config_team_grants_created_by_type_check": { + "name": "esp_config_team_grants_created_by_type_check", + "value": "\"esp_config_team_grants\".\"created_by_type\" IN ('user', 'organization_key', 'system')" } }, "isRLSEnabled": false }, - "public.billing_webhook_events": { - "name": "billing_webhook_events", + "public.esp_configs": { + "name": "esp_configs", "schema": "", "columns": { "id": { @@ -1348,38 +1585,83 @@ "primaryKey": true, "notNull": true }, - "provider": { - "name": "provider", + "esp_id": { + "name": "esp_id", "type": "text", "primaryKey": false, "notNull": true }, - "provider_event_id": { - "name": "provider_event_id", + "owner_scope": { + "name": "owner_scope", "type": "text", "primaryKey": false, "notNull": true }, - "event_type": { - "name": "event_type", + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true }, - "occurred_at": { - "name": "occurred_at", - "type": "timestamp with time zone", + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'smtp'" + }, + "host": { + "name": "host", + "type": "text", "primaryKey": false, "notNull": true }, - "payload_encrypted": { - "name": "payload_encrypted", + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 587 + }, + "secure": { + "name": "secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", "type": "text", "primaryKey": false, "notNull": false }, - "payload_key_version": { - "name": "payload_key_version", + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", "type": "text", "primaryKey": false, "notNull": false @@ -1389,93 +1671,87 @@ "type": "text", "primaryKey": false, "notNull": true, - "default": "'pending'" + "default": "'draft'" }, - "processing_attempts": { - "name": "processing_attempts", + "secret_version": { + "name": "secret_version", "type": "integer", "primaryKey": false, "notNull": true, - "default": 0 + "default": 1 }, - "last_error": { - "name": "last_error", + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", "type": "text", "primaryKey": false, "notNull": false }, - "available_at": { - "name": "available_at", - "type": "timestamp with time zone", + "last_test_error": { + "name": "last_test_error", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false }, - "locked_at": { - "name": "locked_at", + "activated_at": { + "name": "activated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "lease_expires_at": { - "name": "lease_expires_at", + "drain_until": { + "name": "drain_until", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "worker_id": { - "name": "worker_id", - "type": "text", + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "received_at": { - "name": "received_at", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, - "processed_at": { - "name": "processed_at", + "updated_at": { + "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" } }, "indexes": { - "billing_webhook_events_provider_event_uidx": { - "name": "billing_webhook_events_provider_event_uidx", + "esp_configs_organization_id_idx": { + "name": "esp_configs_organization_id_idx", "columns": [ { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_event_id", + "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, - "billing_webhook_events_queue_idx": { - "name": "billing_webhook_events_queue_idx", + "esp_configs_team_id_idx": { + "name": "esp_configs_team_id_idx", "columns": [ { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "available_at", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" @@ -1487,72 +1763,158 @@ "with": {} } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "billing_webhook_events_status_check": { - "name": "billing_webhook_events_status_check", - "value": "\"billing_webhook_events\".\"status\" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')" + "foreignKeys": { + "esp_configs_organization_id_organizations_id_fk": { + "name": "esp_configs_organization_id_organizations_id_fk", + "tableFrom": "esp_configs", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_configs_team_id_teams_id_fk": { + "name": "esp_configs_team_id_teams_id_fk", + "tableFrom": "esp_configs", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" } }, - "isRLSEnabled": false - }, - "public.contact_custom_field_values": { - "name": "contact_custom_field_values", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_configs_esp_id_unique": { + "name": "esp_configs_esp_id_unique", + "nullsNotDistinct": false, + "columns": ["esp_id"] }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true + "esp_configs_id_organization_id_unique": { + "name": "esp_configs_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_id"] }, - "contact_id": { - "name": "contact_id", + "esp_configs_id_team_id_unique": { + "name": "esp_configs_id_team_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "team_id"] + } + }, + "policies": {}, + "checkConstraints": { + "esp_configs_esp_id_check": { + "name": "esp_configs_esp_id_check", + "value": "\"esp_configs\".\"esp_id\" ~ '^esp_'" + }, + "esp_configs_owner_check": { + "name": "esp_configs_owner_check", + "value": "(\"esp_configs\".\"owner_scope\" = 'organization' AND \"esp_configs\".\"organization_id\" IS NOT NULL AND \"esp_configs\".\"team_id\" IS NULL)\n OR (\"esp_configs\".\"owner_scope\" = 'team' AND \"esp_configs\".\"organization_id\" IS NULL AND \"esp_configs\".\"team_id\" IS NOT NULL)" + }, + "esp_configs_status_check": { + "name": "esp_configs_status_check", + "value": "\"esp_configs\".\"status\" IN ('draft', 'active', 'suspended', 'draining', 'retired')" + } + }, + "isRLSEnabled": false + }, + "public.esp_feedback_connections": { + "name": "esp_feedback_connections", + "schema": "", + "columns": { + "id": { + "name": "id", "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", "primaryKey": false, "notNull": true }, - "key": { - "name": "key", + "owner_scope": { + "name": "owner_scope", "type": "text", "primaryKey": false, "notNull": true }, - "value_type": { - "name": "value_type", + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true }, - "value_text": { - "name": "value_text", + "encrypted_credentials": { + "name": "encrypted_credentials", "type": "text", "primaryKey": false, "notNull": false }, - "value_number": { - "name": "value_number", - "type": "double precision", + "previous_encrypted_credentials": { + "name": "previous_encrypted_credentials", + "type": "text", "primaryKey": false, "notNull": false }, - "value_boolean": { - "name": "value_boolean", - "type": "boolean", + "previous_credential_expires_at": { + "name": "previous_credential_expires_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "value_date": { - "name": "value_date", + "expected_topic_arn": { + "name": "expected_topic_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false @@ -1573,107 +1935,14 @@ } }, "indexes": { - "contact_custom_field_values_contact_key_idx": { - "name": "contact_custom_field_values_contact_key_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "contact_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "contact_custom_field_values_text_lookup_idx": { - "name": "contact_custom_field_values_text_lookup_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "value_text", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "contact_custom_field_values_number_lookup_idx": { - "name": "contact_custom_field_values_number_lookup_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "value_number", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "contact_custom_field_values_boolean_lookup_idx": { - "name": "contact_custom_field_values_boolean_lookup_idx", + "esp_feedback_connections_team_id_idx": { + "name": "esp_feedback_connections_team_id_idx", "columns": [ { "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "value_boolean", - "isExpression": false, - "asc": true, - "nulls": "last" } ], "isUnique": false, @@ -1681,62 +1950,75 @@ "method": "btree", "with": {} }, - "contact_custom_field_values_date_lookup_idx": { - "name": "contact_custom_field_values_date_lookup_idx", + "esp_feedback_connections_esp_config_active_idx": { + "name": "esp_feedback_connections_esp_config_active_idx", "columns": [ { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "value_date", + "expression": "esp_config_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, + "where": "\"esp_feedback_connections\".\"esp_config_id\" is not null and \"esp_feedback_connections\".\"status\" not in ('retiring', 'disabled')", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "contact_custom_field_values_team_id_teams_id_fk": { - "name": "contact_custom_field_values_team_id_teams_id_fk", - "tableFrom": "contact_custom_field_values", + "esp_feedback_connections_organization_id_organizations_id_fk": { + "name": "esp_feedback_connections_organization_id_organizations_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_team_id_teams_id_fk": { + "name": "esp_feedback_connections_team_id_teams_id_fk", + "tableFrom": "esp_feedback_connections", "tableTo": "teams", "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" }, - "contact_custom_field_values_contact_id_contacts_id_fk": { - "name": "contact_custom_field_values_contact_id_contacts_id_fk", - "tableFrom": "contact_custom_field_values", - "tableTo": "contacts", - "columnsFrom": ["contact_id"], + "esp_feedback_connections_esp_config_id_esp_configs_id_fk": { + "name": "esp_feedback_connections_esp_config_id_esp_configs_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "esp_feedback_connections_connection_id_unique": { + "name": "esp_feedback_connections_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "esp_feedback_connections_connection_id_check": { + "name": "esp_feedback_connections_connection_id_check", + "value": "\"esp_feedback_connections\".\"connection_id\" ~ '^whc_'" + }, + "esp_feedback_connections_owner_check": { + "name": "esp_feedback_connections_owner_check", + "value": "(\n \"esp_feedback_connections\".\"owner_scope\" = 'organization'\n AND \"esp_feedback_connections\".\"organization_id\" IS NOT NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NULL\n ) OR (\n \"esp_feedback_connections\".\"owner_scope\" = 'team'\n AND \"esp_feedback_connections\".\"organization_id\" IS NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NOT NULL\n )" + } + }, "isRLSEnabled": false }, - "public.contacts": { - "name": "contacts", + "public.esp_webhook_receipts": { + "name": "esp_webhook_receipts", "schema": "", "columns": { "id": { @@ -1745,99 +2027,152 @@ "primaryKey": true, "notNull": true }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, "team_id": { "name": "team_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, - "contact_id": { - "name": "contact_id", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true }, - "email": { - "name": "email", + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_sha256": { + "name": "body_sha256", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "encrypted_payload": { + "name": "encrypted_payload", "type": "text", "primaryKey": false, "notNull": false }, - "subscribed": { - "name": "subscribed", - "type": "boolean", + "safe_headers": { + "name": "safe_headers", + "type": "jsonb", "primaryKey": false, "notNull": true, - "default": true + "default": "'{}'::jsonb" }, - "custom_fields": { - "name": "custom_fields", - "type": "jsonb", + "status": { + "name": "status", + "type": "text", "primaryKey": false, "notNull": true, - "default": "'{}'::jsonb" + "default": "'pending'" }, - "tags": { - "name": "tags", - "type": "text[]", + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", "primaryKey": false, "notNull": true, - "default": "'{}'" + "default": 0 }, - "unsubscribe_token": { - "name": "unsubscribe_token", + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "created_at": { - "name": "created_at", + "received_at": { + "name": "received_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", + "processed_at": { + "name": "processed_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": false } }, "indexes": { - "contacts_team_id_email_idx": { - "name": "contacts_team_id_email_idx", + "esp_webhook_receipts_status_next_attempt_idx": { + "name": "esp_webhook_receipts_status_next_attempt_idx", "columns": [ { - "expression": "team_id", + "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "email", + "expression": "next_attempt_at", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_webhook_receipts_connection_id_provider_request_id_idx": { + "name": "esp_webhook_receipts_connection_id_provider_request_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "contacts_team_id_teams_id_fk": { - "name": "contacts_team_id_teams_id_fk", - "tableFrom": "contacts", + "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk": { + "name": "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "esp_webhook_receipts_team_id_teams_id_fk": { + "name": "esp_webhook_receipts_team_id_teams_id_fk", + "tableFrom": "esp_webhook_receipts", "tableTo": "teams", "columnsFrom": ["team_id"], "columnsTo": ["id"], @@ -1847,287 +2182,176 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "contacts_contact_id_unique": { - "name": "contacts_contact_id_unique", - "nullsNotDistinct": false, - "columns": ["contact_id"] - }, - "contacts_unsubscribe_token_unique": { - "name": "contacts_unsubscribe_token_unique", + "esp_webhook_receipts_receipt_id_unique": { + "name": "esp_webhook_receipts_receipt_id_unique", "nullsNotDistinct": false, - "columns": ["unsubscribe_token"] + "columns": ["receipt_id"] } }, "policies": {}, "checkConstraints": { - "contacts_contact_id_check": { - "name": "contacts_contact_id_check", - "value": "\"contacts\".\"contact_id\" ~ '^cnt_'" + "esp_webhook_receipts_receipt_id_check": { + "name": "esp_webhook_receipts_receipt_id_check", + "value": "\"esp_webhook_receipts\".\"receipt_id\" ~ '^whr_'" } }, "isRLSEnabled": false }, - "public.email_deliveries": { - "name": "email_deliveries", + "public.jwks": { + "name": "jwks", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "public_key": { + "name": "public_key", + "type": "text", "primaryKey": false, "notNull": true }, - "sequence_id": { - "name": "sequence_id", - "type": "uuid", + "private_key": { + "name": "private_key", + "type": "text", "primaryKey": false, "notNull": true }, - "contact_id": { - "name": "contact_id", - "type": "uuid", + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, - "email_id": { - "name": "email_id", - "type": "uuid", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "alg": { + "name": "alg", + "type": "text", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false } }, "indexes": {}, - "foreignKeys": { - "email_deliveries_team_id_teams_id_fk": { - "name": "email_deliveries_team_id_teams_id_fk", - "tableFrom": "email_deliveries", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_dispatch_outbox": { + "name": "mail_dispatch_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true }, - "email_deliveries_sequence_id_sequences_id_fk": { - "name": "email_deliveries_sequence_id_sequences_id_fk", - "tableFrom": "email_deliveries", - "tableTo": "sequences", - "columnsFrom": ["sequence_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_deliveries_contact_id_contacts_id_fk": { - "name": "email_deliveries_contact_id_contacts_id_fk", - "tableFrom": "email_deliveries", - "tableTo": "contacts", - "columnsFrom": ["contact_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_deliveries_email_id_sequence_emails_id_fk": { - "name": "email_deliveries_email_id_sequence_emails_id_fk", - "tableFrom": "email_deliveries", - "tableTo": "sequence_emails", - "columnsFrom": ["email_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_delivery_events": { - "name": "email_delivery_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "event_id": { - "name": "event_id", + "dispatch_id": { + "name": "dispatch_id", "type": "text", "primaryKey": false, "notNull": true }, - "receipt_id": { - "name": "receipt_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "connection_id": { - "name": "connection_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, "outbound_message_id": { "name": "outbound_message_id", "type": "uuid", "primaryKey": false, - "notNull": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, "notNull": true }, - "provider_event_key": { - "name": "provider_event_key", + "queue_name": { + "name": "queue_name", "type": "text", "primaryKey": false, "notNull": true }, - "provider_message_id": { - "name": "provider_message_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "recipient_email": { - "name": "recipient_email", + "job_name": { + "name": "job_name", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "normalized_recipient": { - "name": "normalized_recipient", + "state": { + "name": "state", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'pending'" }, - "event_type": { - "name": "event_type", - "type": "text", + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, - "bounce_class": { - "name": "bounce_class", - "type": "text", + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "smtp_code": { - "name": "smtp_code", + "publish_attempts": { + "name": "publish_attempts", "type": "integer", "primaryKey": false, - "notNull": false - }, - "enhanced_status_code": { - "name": "enhanced_status_code", - "type": "text", - "primaryKey": false, - "notNull": false + "notNull": true, + "default": 0 }, - "reason": { - "name": "reason", + "last_error": { + "name": "last_error", "type": "text", "primaryKey": false, "notNull": false }, - "remote_mta": { - "name": "remote_mta", - "type": "text", + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "occurred_at": { - "name": "occurred_at", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, - "received_at": { - "name": "received_at", + "updated_at": { + "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, "notNull": true, - "default": "'{}'::jsonb" + "default": "now()" } }, "indexes": { - "email_delivery_events_connection_id_provider_event_key_idx": { - "name": "email_delivery_events_connection_id_provider_event_key_idx", - "columns": [ - { - "expression": "connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_event_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "email_delivery_events_team_id_occurred_at_idx": { - "name": "email_delivery_events_team_id_occurred_at_idx", + "mail_dispatch_outbox_due_idx": { + "name": "mail_dispatch_outbox_due_idx", "columns": [ { - "expression": "team_id", + "expression": "state", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "email_delivery_events_outbound_message_id_idx": { - "name": "email_delivery_events_outbound_message_id_idx", - "columns": [ - { - "expression": "outbound_message_id", + "expression": "available_at", "isExpression": false, "asc": true, "nulls": "last" @@ -2140,62 +2364,44 @@ } }, "foreignKeys": { - "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk": { - "name": "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk", - "tableFrom": "email_delivery_events", - "tableTo": "esp_webhook_receipts", - "columnsFrom": ["receipt_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_delivery_events_connection_id_esp_feedback_connections_id_fk": { - "name": "email_delivery_events_connection_id_esp_feedback_connections_id_fk", - "tableFrom": "email_delivery_events", - "tableTo": "esp_feedback_connections", - "columnsFrom": ["connection_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_delivery_events_team_id_teams_id_fk": { - "name": "email_delivery_events_team_id_teams_id_fk", - "tableFrom": "email_delivery_events", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_delivery_events_outbound_message_id_outbound_messages_id_fk": { - "name": "email_delivery_events_outbound_message_id_outbound_messages_id_fk", - "tableFrom": "email_delivery_events", + "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk": { + "name": "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "mail_dispatch_outbox", "tableTo": "outbound_messages", "columnsFrom": ["outbound_message_id"], "columnsTo": ["id"], - "onDelete": "set null", + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "email_delivery_events_event_id_unique": { - "name": "email_delivery_events_event_id_unique", + "mail_dispatch_outbox_dispatch_id_unique": { + "name": "mail_dispatch_outbox_dispatch_id_unique", "nullsNotDistinct": false, - "columns": ["event_id"] + "columns": ["dispatch_id"] + }, + "mail_dispatch_outbox_outbound_message_id_unique": { + "name": "mail_dispatch_outbox_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": ["outbound_message_id"] } }, "policies": {}, "checkConstraints": { - "email_delivery_events_event_id_check": { - "name": "email_delivery_events_event_id_check", - "value": "\"email_delivery_events\".\"event_id\" ~ '^evt_'" + "mail_dispatch_outbox_dispatch_id_check": { + "name": "mail_dispatch_outbox_dispatch_id_check", + "value": "\"mail_dispatch_outbox\".\"dispatch_id\" ~ '^mdj_'" + }, + "mail_dispatch_outbox_state_check": { + "name": "mail_dispatch_outbox_state_check", + "value": "\"mail_dispatch_outbox\".\"state\" IN ('pending', 'publishing', 'published', 'cancelled')" } }, "isRLSEnabled": false }, - "public.email_events": { - "name": "email_events", + "public.media": { + "name": "media", "schema": "", "columns": { "id": { @@ -2210,50 +2416,68 @@ "primaryKey": false, "notNull": true }, - "sequence_id": { - "name": "sequence_id", - "type": "uuid", + "media_id": { + "name": "media_id", + "type": "text", "primaryKey": false, "notNull": true }, - "contact_id": { - "name": "contact_id", - "type": "uuid", + "media_lit_id": { + "name": "media_lit_id", + "type": "text", "primaryKey": false, "notNull": true }, - "email_id": { - "name": "email_id", - "type": "uuid", + "url": { + "name": "url", + "type": "text", "primaryKey": false, "notNull": true }, - "action": { - "name": "action", + "thumbnail_url": { + "name": "thumbnail_url", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "link": { - "name": "link", + "file_name": { + "name": "file_name", "type": "text", "primaryKey": false, "notNull": false }, - "link_index": { - "name": "link_index", + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", "type": "integer", "primaryKey": false, "notNull": false }, - "bounce_type": { - "name": "bounce_type", + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", "type": "text", "primaryKey": false, "notNull": false }, - "bounce_reason": { - "name": "bounce_reason", + "caption": { + "name": "caption", "type": "text", "primaryKey": false, "notNull": false @@ -2264,55 +2488,89 @@ "primaryKey": false, "notNull": false, "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_team_id_media_lit_id_idx": { + "name": "media_team_id_media_lit_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_lit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_team_id_created_at_idx": { + "name": "media_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, - "indexes": {}, "foreignKeys": { - "email_events_team_id_teams_id_fk": { - "name": "email_events_team_id_teams_id_fk", - "tableFrom": "email_events", + "media_team_id_teams_id_fk": { + "name": "media_team_id_teams_id_fk", + "tableFrom": "media", "tableTo": "teams", "columnsFrom": ["team_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "email_events_sequence_id_sequences_id_fk": { - "name": "email_events_sequence_id_sequences_id_fk", - "tableFrom": "email_events", - "tableTo": "sequences", - "columnsFrom": ["sequence_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_events_contact_id_contacts_id_fk": { - "name": "email_events_contact_id_contacts_id_fk", - "tableFrom": "email_events", - "tableTo": "contacts", - "columnsFrom": ["contact_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_events_email_id_sequence_emails_id_fk": { - "name": "email_events_email_id_sequence_emails_id_fk", - "tableFrom": "email_events", - "tableTo": "sequence_emails", - "columnsFrom": ["email_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "media_media_id_unique": { + "name": "media_media_id_unique", + "nullsNotDistinct": false, + "columns": ["media_id"] + } + }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "media_media_id_check": { + "name": "media_media_id_check", + "value": "\"media\".\"media_id\" ~ '^med_'" + } + }, "isRLSEnabled": false }, - "public.email_suppression_actions": { - "name": "email_suppression_actions", + "public.media_references": { + "name": "media_references", "schema": "", "columns": { "id": { @@ -2327,38 +2585,38 @@ "primaryKey": false, "notNull": true }, - "suppression_id": { - "name": "suppression_id", + "media_id": { + "name": "media_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "source_event_id": { - "name": "source_event_id", - "type": "uuid", + "resource_type": { + "name": "resource_type", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "action": { - "name": "action", - "type": "text", + "resource_internal_id": { + "name": "resource_internal_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "actor_type": { - "name": "actor_type", + "resource_public_id": { + "name": "resource_public_id", "type": "text", "primaryKey": false, "notNull": true }, - "actor_user_id": { - "name": "actor_user_id", - "type": "text", + "parent_resource_internal_id": { + "name": "parent_resource_internal_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "explanation": { - "name": "explanation", + "parent_resource_public_id": { + "name": "parent_resource_public_id", "type": "text", "primaryKey": false, "notNull": false @@ -2367,22 +2625,35 @@ "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, "default": "now()" } }, "indexes": { - "email_suppression_actions_suppression_id_created_at_idx": { - "name": "email_suppression_actions_suppression_id_created_at_idx", + "media_references_resource_idx": { + "name": "media_references_resource_idx", "columns": [ { - "expression": "suppression_id", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", "isExpression": false, "asc": true, "nulls": "last" @@ -2392,43 +2663,73 @@ "concurrently": false, "method": "btree", "with": {} - } - }, - "foreignKeys": { - "email_suppression_actions_team_id_teams_id_fk": { - "name": "email_suppression_actions_team_id_teams_id_fk", - "tableFrom": "email_suppression_actions", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_suppression_actions_suppression_id_email_suppressions_id_fk": { - "name": "email_suppression_actions_suppression_id_email_suppressions_id_fk", - "tableFrom": "email_suppression_actions", - "tableTo": "email_suppressions", - "columnsFrom": ["suppression_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" }, - "email_suppression_actions_source_event_id_email_delivery_events_id_fk": { - "name": "email_suppression_actions_source_event_id_email_delivery_events_id_fk", - "tableFrom": "email_suppression_actions", - "tableTo": "email_delivery_events", - "columnsFrom": ["source_event_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" + "media_references_media_id_idx": { + "name": "media_references_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} }, - "email_suppression_actions_actor_user_id_user_id_fk": { - "name": "email_suppression_actions_actor_user_id_user_id_fk", - "tableFrom": "email_suppression_actions", - "tableTo": "user", - "columnsFrom": ["actor_user_id"], + "media_references_resource_media_idx": { + "name": "media_references_resource_media_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_references_team_id_teams_id_fk": { + "name": "media_references_team_id_teams_id_fk", + "tableFrom": "media_references", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "set null", + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_references_media_id_media_id_fk": { + "name": "media_references_media_id_media_id_fk", + "tableFrom": "media_references", + "tableTo": "media", + "columnsFrom": ["media_id"], + "columnsTo": ["id"], + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -2438,151 +2739,161 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.email_suppressions": { - "name": "email_suppressions", + "public.oauth_access_token": { + "name": "oauth_access_token", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true }, - "suppression_id": { - "name": "suppression_id", + "token": { + "name": "token", "type": "text", "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "client_id": { + "name": "client_id", + "type": "text", "primaryKey": false, "notNull": true }, - "recipient_email": { - "name": "recipient_email", + "session_id": { + "name": "session_id", "type": "text", "primaryKey": false, "notNull": false }, - "normalized_recipient": { - "name": "normalized_recipient", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": false }, - "recipient_hash": { - "name": "recipient_hash", + "reference_id": { + "name": "reference_id", "type": "text", "primaryKey": false, - "notNull": true - }, - "hash_key_version": { - "name": "hash_key_version", - "type": "integer", - "primaryKey": false, - "notNull": true + "notNull": false }, - "reason": { - "name": "reason", + "authorization_code_id": { + "name": "authorization_code_id", "type": "text", "primaryKey": false, - "notNull": true - }, - "source_event_id": { - "name": "source_event_id", - "type": "uuid", - "primaryKey": false, "notNull": false }, - "active": { - "name": "active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "first_suppressed_at": { - "name": "first_suppressed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "last_suppressed_at": { - "name": "last_suppressed_at", - "type": "timestamp with time zone", + "resources": { + "name": "resources", + "type": "text[]", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false }, - "released_at": { - "name": "released_at", - "type": "timestamp with time zone", + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", "primaryKey": false, "notNull": false }, - "released_by": { - "name": "released_by", + "refresh_id": { + "name": "refresh_id", "type": "text", "primaryKey": false, "notNull": false }, - "release_reason": { - "name": "release_reason", - "type": "text", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", + "scopes": { + "name": "scopes", + "type": "text[]", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false } }, "indexes": { - "email_suppressions_team_id_recipient_hash_idx": { - "name": "email_suppressions_team_id_recipient_hash_idx", + "auth_oauth_access_token_client_id_idx": { + "name": "auth_oauth_access_token_client_id_idx", "columns": [ { - "expression": "team_id", + "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" - }, + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_session_id_idx": { + "name": "auth_oauth_access_token_session_id_idx", + "columns": [ { - "expression": "recipient_hash", + "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, - "email_suppressions_team_id_active_idx": { - "name": "email_suppressions_team_id_active_idx", + "auth_oauth_access_token_user_id_idx": { + "name": "auth_oauth_access_token_user_id_idx", "columns": [ { - "expression": "team_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" - }, + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_authorization_code_id_idx": { + "name": "auth_oauth_access_token_authorization_code_id_idx", + "columns": [ { - "expression": "active", + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_refresh_id_idx": { + "name": "auth_oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", "isExpression": false, "asc": true, "nulls": "last" @@ -2595,29 +2906,38 @@ } }, "foreignKeys": { - "email_suppressions_team_id_teams_id_fk": { - "name": "email_suppressions_team_id_teams_id_fk", - "tableFrom": "email_suppressions", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], + "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" }, - "email_suppressions_source_event_id_email_delivery_events_id_fk": { - "name": "email_suppressions_source_event_id_email_delivery_events_id_fk", - "tableFrom": "email_suppressions", - "tableTo": "email_delivery_events", - "columnsFrom": ["source_event_id"], + "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" }, - "email_suppressions_released_by_user_id_fk": { - "name": "email_suppressions_released_by_user_id_fk", - "tableFrom": "email_suppressions", + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", "tableTo": "user", - "columnsFrom": ["released_by"], + "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": "set null", "onUpdate": "no action" @@ -2625,469 +2945,373 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "email_suppressions_suppression_id_unique": { - "name": "email_suppressions_suppression_id_unique", + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", "nullsNotDistinct": false, - "columns": ["suppression_id"] + "columns": ["token"] } }, "policies": {}, - "checkConstraints": { - "email_suppressions_suppression_id_check": { - "name": "email_suppressions_suppression_id_check", - "value": "\"email_suppressions\".\"suppression_id\" ~ '^sup_'" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.email_templates": { - "name": "email_templates", + "public.oauth_client": { + "name": "oauth_client", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "client_id": { + "name": "client_id", + "type": "text", "primaryKey": false, "notNull": true }, - "template_id": { - "name": "template_id", + "client_secret": { + "name": "client_secret", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "title": { - "name": "title", + "client_discovery_id": { + "name": "client_discovery_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "purpose": { - "name": "purpose", + "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 + }, + "subject_type": { + "name": "subject_type", "type": "text", "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, "notNull": true, - "default": "'marketing'" + "default": "'{}'" }, - "content": { - "name": "content", - "type": "jsonb", + "user_id": { + "name": "user_id", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": false }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": { - "email_templates_team_id_title_idx": { - "name": "email_templates_team_id_title_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "title", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "email_templates_team_id_teams_id_fk": { - "name": "email_templates_team_id_teams_id_fk", - "tableFrom": "email_templates", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "email_templates_template_id_unique": { - "name": "email_templates_template_id_unique", - "nullsNotDistinct": false, - "columns": ["template_id"] - } - }, - "policies": {}, - "checkConstraints": { - "email_templates_template_id_check": { - "name": "email_templates_template_id_check", - "value": "\"email_templates\".\"template_id\" ~ '^tpl_'" + "notNull": false }, - "email_templates_purpose_check": { - "name": "email_templates_purpose_check", - "value": "\"email_templates\".\"purpose\" in ('marketing', 'transactional')" - } - }, - "isRLSEnabled": false - }, - "public.esp_config_team_grants": { - "name": "esp_config_team_grants", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false }, - "grant_id": { - "name": "grant_id", + "uri": { + "name": "uri", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "organization_id": { - "name": "organization_id", - "type": "uuid", + "icon": { + "name": "icon", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "esp_config_id": { - "name": "esp_config_id", - "type": "uuid", + "contacts": { + "name": "contacts", + "type": "text[]", "primaryKey": false, - "notNull": true + "notNull": false }, - "team_id": { - "name": "team_id", - "type": "uuid", + "tos": { + "name": "tos", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "status": { - "name": "status", + "policy": { + "name": "policy", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'active'" + "notNull": false }, - "drain_until": { - "name": "drain_until", - "type": "timestamp with time zone", + "software_id": { + "name": "software_id", + "type": "text", "primaryKey": false, "notNull": false }, - "from_name": { - "name": "from_name", + "software_version": { + "name": "software_version", "type": "text", "primaryKey": false, "notNull": false }, - "reply_to": { - "name": "reply_to", + "software_statement": { + "name": "software_statement", "type": "text", "primaryKey": false, "notNull": false }, - "daily_limit": { - "name": "daily_limit", - "type": "integer", + "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 }, - "monthly_limit": { - "name": "monthly_limit", - "type": "integer", + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", "primaryKey": false, "notNull": false }, - "created_by_type": { - "name": "created_by_type", + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "created_by_id": { - "name": "created_by_id", + "application_type": { + "name": "application_type", "type": "text", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "jwks": { + "name": "jwks", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", + "jwks_uri": { + "name": "jwks_uri", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "now()" - } + "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 + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } }, "indexes": { - "esp_config_team_grants_non_revoked_team_idx": { - "name": "esp_config_team_grants_non_revoked_team_idx", + "auth_oauth_client_user_id_idx": { + "name": "auth_oauth_client_user_id_idx", "columns": [ { - "expression": "team_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, - "where": "\"esp_config_team_grants\".\"status\" <> 'revoked'", + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "esp_config_team_grants_team_organization_fk": { - "name": "esp_config_team_grants_team_organization_fk", - "tableFrom": "esp_config_team_grants", - "tableTo": "teams", - "columnsFrom": ["team_id", "organization_id"], - "columnsTo": ["id", "organization_id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "esp_config_team_grants_esp_organization_fk": { - "name": "esp_config_team_grants_esp_organization_fk", - "tableFrom": "esp_config_team_grants", - "tableTo": "esp_configs", - "columnsFrom": ["esp_config_id", "organization_id"], - "columnsTo": ["id", "organization_id"], - "onDelete": "restrict", + "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": { - "esp_config_team_grants_grant_id_unique": { - "name": "esp_config_team_grants_grant_id_unique", - "nullsNotDistinct": false, - "columns": ["grant_id"] - }, - "esp_config_team_grants_id_organization_id_unique": { - "name": "esp_config_team_grants_id_organization_id_unique", - "nullsNotDistinct": false, - "columns": ["id", "organization_id"] - }, - "esp_config_team_grants_id_team_esp_unique": { - "name": "esp_config_team_grants_id_team_esp_unique", + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", "nullsNotDistinct": false, - "columns": ["id", "team_id", "esp_config_id"] + "columns": ["client_id"] } }, "policies": {}, - "checkConstraints": { - "esp_config_team_grants_public_id_check": { - "name": "esp_config_team_grants_public_id_check", - "value": "\"esp_config_team_grants\".\"grant_id\" ~ '^egr_'" - }, - "esp_config_team_grants_status_check": { - "name": "esp_config_team_grants_status_check", - "value": "\"esp_config_team_grants\".\"status\" IN ('active', 'draining', 'suspended', 'revoked')" - }, - "esp_config_team_grants_limit_check": { - "name": "esp_config_team_grants_limit_check", - "value": "(\"esp_config_team_grants\".\"daily_limit\" IS NULL OR \"esp_config_team_grants\".\"daily_limit\" >= 0)\n AND (\"esp_config_team_grants\".\"monthly_limit\" IS NULL OR \"esp_config_team_grants\".\"monthly_limit\" >= 0)" - }, - "esp_config_team_grants_created_by_type_check": { - "name": "esp_config_team_grants_created_by_type_check", - "value": "\"esp_config_team_grants\".\"created_by_type\" IN ('user', 'organization_key', 'system')" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.esp_configs": { - "name": "esp_configs", + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true }, - "esp_id": { - "name": "esp_id", - "type": "text", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true - }, - "owner_scope": { - "name": "owner_scope", + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", + "client_id": { + "name": "client_id", "type": "text", "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'smtp'" - }, - "host": { - "name": "host", + "resource_id": { + "name": "resource_id", "type": "text", "primaryKey": false, "notNull": true }, - "port": { - "name": "port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 587 - }, - "secure": { - "name": "secure", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "encrypted_secret": { - "name": "encrypted_secret", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "from_name": { - "name": "from_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "from_email": { - "name": "from_email", - "type": "text", + "metadata": { + "name": "metadata", + "type": "jsonb", "primaryKey": false, "notNull": false }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'draft'" - }, - "secret_version": { - "name": "secret_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "last_tested_at": { - "name": "last_tested_at", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false + } + }, + "indexes": { + "auth_oauth_client_resource_client_id_idx": { + "name": "auth_oauth_client_resource_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} }, - "last_test_status": { - "name": "last_test_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_test_error": { - "name": "last_test_error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "activated_at": { - "name": "activated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "drain_until": { - "name": "drain_until", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "retired_at": { - "name": "retired_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "esp_configs_organization_id_idx": { - "name": "esp_configs_organization_id_idx", + "auth_oauth_client_resource_resource_id_idx": { + "name": "auth_oauth_client_resource_resource_id_idx", "columns": [ { - "expression": "organization_id", + "expression": "resource_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3098,199 +3322,119 @@ "method": "btree", "with": {} }, - "esp_configs_team_id_idx": { - "name": "esp_configs_team_id_idx", + "auth_oauth_client_resource_client_id_resource_id_idx": { + "name": "auth_oauth_client_resource_client_id_resource_id_idx", "columns": [ { - "expression": "team_id", + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "esp_configs_organization_id_organizations_id_fk": { - "name": "esp_configs_organization_id_organizations_id_fk", - "tableFrom": "esp_configs", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", "onUpdate": "no action" }, - "esp_configs_team_id_teams_id_fk": { - "name": "esp_configs_team_id_teams_id_fk", - "tableFrom": "esp_configs", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "restrict", + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["identifier"], + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "esp_configs_esp_id_unique": { - "name": "esp_configs_esp_id_unique", - "nullsNotDistinct": false, - "columns": ["esp_id"] - }, - "esp_configs_id_organization_id_unique": { - "name": "esp_configs_id_organization_id_unique", - "nullsNotDistinct": false, - "columns": ["id", "organization_id"] - }, - "esp_configs_id_team_id_unique": { - "name": "esp_configs_id_team_id_unique", - "nullsNotDistinct": false, - "columns": ["id", "team_id"] - } - }, + "uniqueConstraints": {}, "policies": {}, - "checkConstraints": { - "esp_configs_esp_id_check": { - "name": "esp_configs_esp_id_check", - "value": "\"esp_configs\".\"esp_id\" ~ '^esp_'" - }, - "esp_configs_owner_check": { - "name": "esp_configs_owner_check", - "value": "(\"esp_configs\".\"owner_scope\" = 'organization' AND \"esp_configs\".\"organization_id\" IS NOT NULL AND \"esp_configs\".\"team_id\" IS NULL)\n OR (\"esp_configs\".\"owner_scope\" = 'team' AND \"esp_configs\".\"organization_id\" IS NULL AND \"esp_configs\".\"team_id\" IS NOT NULL)" - }, - "esp_configs_status_check": { - "name": "esp_configs_status_check", - "value": "\"esp_configs\".\"status\" IN ('draft', 'active', 'suspended', 'draining', 'retired')" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.esp_feedback_connections": { - "name": "esp_feedback_connections", + "public.oauth_consent": { + "name": "oauth_consent", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner_scope": { - "name": "owner_scope", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "esp_config_id": { - "name": "esp_config_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "provider": { - "name": "provider", + "client_id": { + "name": "client_id", "type": "text", "primaryKey": false, "notNull": true }, - "encrypted_credentials": { - "name": "encrypted_credentials", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "previous_encrypted_credentials": { - "name": "previous_encrypted_credentials", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "previous_credential_expires_at": { - "name": "previous_credential_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "expected_topic_arn": { - "name": "expected_topic_arn", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": false }, - "status": { - "name": "status", + "reference_id": { + "name": "reference_id", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "last_received_at": { - "name": "last_received_at", - "type": "timestamp with time zone", - "primaryKey": false, "notNull": false }, - "last_verified_at": { - "name": "last_verified_at", - "type": "timestamp with time zone", + "resources": { + "name": "resources", + "type": "text[]", "primaryKey": false, "notNull": false }, - "last_error_code": { - "name": "last_error_code", - "type": "text", + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", "primaryKey": false, "notNull": false }, - "disabled_at": { - "name": "disabled_at", - "type": "timestamp with time zone", + "scopes": { + "name": "scopes", + "type": "text[]", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true } }, "indexes": { - "esp_feedback_connections_team_id_idx": { - "name": "esp_feedback_connections_team_id_idx", + "auth_oauth_consent_client_id_idx": { + "name": "auth_oauth_consent_client_id_idx", "columns": [ { - "expression": "team_id", + "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3301,184 +3445,233 @@ "method": "btree", "with": {} }, - "esp_feedback_connections_esp_config_active_idx": { - "name": "esp_feedback_connections_esp_config_active_idx", + "auth_oauth_consent_user_id_idx": { + "name": "auth_oauth_consent_user_id_idx", "columns": [ { - "expression": "esp_config_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, - "where": "\"esp_feedback_connections\".\"esp_config_id\" is not null and \"esp_feedback_connections\".\"status\" not in ('retiring', 'disabled')", + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "esp_feedback_connections_organization_id_organizations_id_fk": { - "name": "esp_feedback_connections_organization_id_organizations_id_fk", - "tableFrom": "esp_feedback_connections", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", + "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" }, - "esp_feedback_connections_team_id_teams_id_fk": { - "name": "esp_feedback_connections_team_id_teams_id_fk", - "tableFrom": "esp_feedback_connections", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "esp_feedback_connections_esp_config_id_esp_configs_id_fk": { - "name": "esp_feedback_connections_esp_config_id_esp_configs_id_fk", - "tableFrom": "esp_feedback_connections", - "tableTo": "esp_configs", - "columnsFrom": ["esp_config_id"], + "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": "restrict", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "esp_feedback_connections_connection_id_unique": { - "name": "esp_feedback_connections_connection_id_unique", - "nullsNotDistinct": false, - "columns": ["connection_id"] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_post_login_team_selections": { + "name": "oauth_post_login_team_selections", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" } }, - "policies": {}, - "checkConstraints": { - "esp_feedback_connections_connection_id_check": { - "name": "esp_feedback_connections_connection_id_check", - "value": "\"esp_feedback_connections\".\"connection_id\" ~ '^whc_'" + "indexes": {}, + "foreignKeys": { + "oauth_post_login_team_selections_session_id_session_id_fk": { + "name": "oauth_post_login_team_selections_session_id_session_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" }, - "esp_feedback_connections_owner_check": { - "name": "esp_feedback_connections_owner_check", - "value": "(\n \"esp_feedback_connections\".\"owner_scope\" = 'organization'\n AND \"esp_feedback_connections\".\"organization_id\" IS NOT NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NULL\n ) OR (\n \"esp_feedback_connections\".\"owner_scope\" = 'team'\n AND \"esp_feedback_connections\".\"organization_id\" IS NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NOT NULL\n )" + "oauth_post_login_team_selections_team_id_teams_id_fk": { + "name": "oauth_post_login_team_selections_team_id_teams_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.esp_webhook_receipts": { - "name": "esp_webhook_receipts", + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true }, - "receipt_id": { - "name": "receipt_id", + "token": { + "name": "token", "type": "text", "primaryKey": false, "notNull": true }, - "connection_id": { - "name": "connection_id", - "type": "uuid", + "client_id": { + "name": "client_id", + "type": "text", "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "session_id": { + "name": "session_id", + "type": "text", "primaryKey": false, "notNull": false }, - "provider": { - "name": "provider", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "provider_request_id": { - "name": "provider_request_id", + "reference_id": { + "name": "reference_id", "type": "text", "primaryKey": false, "notNull": false }, - "body_sha256": { - "name": "body_sha256", + "authorization_code_id": { + "name": "authorization_code_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "encrypted_payload": { - "name": "encrypted_payload", - "type": "text", + "resources": { + "name": "resources", + "type": "text[]", "primaryKey": false, "notNull": false }, - "safe_headers": { - "name": "safe_headers", - "type": "jsonb", + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": false }, - "status": { - "name": "status", - "type": "text", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "'pending'" + "notNull": true }, - "processing_attempts": { - "name": "processing_attempts", - "type": "integer", + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": true }, - "next_attempt_at": { - "name": "next_attempt_at", + "revoked": { + "name": "revoked", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "last_error_code": { - "name": "last_error_code", + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", "type": "text", "primaryKey": false, "notNull": false }, - "received_at": { - "name": "received_at", + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false }, - "processed_at": { - "name": "processed_at", + "auth_time": { + "name": "auth_time", "type": "timestamp with time zone", "primaryKey": false, "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true } }, "indexes": { - "esp_webhook_receipts_status_next_attempt_idx": { - "name": "esp_webhook_receipts_status_next_attempt_idx", + "auth_oauth_refresh_token_client_id_idx": { + "name": "auth_oauth_refresh_token_client_id_idx", "columns": [ { - "expression": "status", + "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" - }, + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_authorization_code_id_idx": { + "name": "auth_oauth_refresh_token_authorization_code_id_idx", + "columns": [ { - "expression": "next_attempt_at", + "expression": "authorization_code_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3489,17 +3682,26 @@ "method": "btree", "with": {} }, - "esp_webhook_receipts_connection_id_provider_request_id_idx": { - "name": "esp_webhook_receipts_connection_id_provider_request_id_idx", + "auth_oauth_refresh_token_session_id_idx": { + "name": "auth_oauth_refresh_token_session_id_idx", "columns": [ { - "expression": "connection_id", + "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" - }, + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_user_id_idx": { + "name": "auth_oauth_refresh_token_user_id_idx", + "columns": [ { - "expression": "provider_request_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3512,20 +3714,29 @@ } }, "foreignKeys": { - "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk": { - "name": "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk", - "tableFrom": "esp_webhook_receipts", - "tableTo": "esp_feedback_connections", - "columnsFrom": ["connection_id"], - "columnsTo": ["id"], + "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" }, - "esp_webhook_receipts_team_id_teams_id_fk": { - "name": "esp_webhook_receipts_team_id_teams_id_fk", - "tableFrom": "esp_webhook_receipts", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "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" @@ -3533,23 +3744,18 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "esp_webhook_receipts_receipt_id_unique": { - "name": "esp_webhook_receipts_receipt_id_unique", + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", "nullsNotDistinct": false, - "columns": ["receipt_id"] + "columns": ["token"] } }, "policies": {}, - "checkConstraints": { - "esp_webhook_receipts_receipt_id_check": { - "name": "esp_webhook_receipts_receipt_id_check", - "value": "\"esp_webhook_receipts\".\"receipt_id\" ~ '^whr_'" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.jwks": { - "name": "jwks", + "public.oauth_resource": { + "name": "oauth_resource", "schema": "", "columns": { "id": { @@ -3558,39 +3764,90 @@ "primaryKey": true, "notNull": true }, - "public_key": { - "name": "public_key", + "identifier": { + "name": "identifier", "type": "text", "primaryKey": false, "notNull": true }, - "private_key": { - "name": "private_key", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false }, - "expires_at": { - "name": "expires_at", + "updated_at": { + "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "alg": { - "name": "alg", - "type": "text", + "policy_version": { + "name": "policy_version", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 1 }, - "crv": { - "name": "crv", - "type": "text", + "metadata": { + "name": "metadata", + "type": "jsonb", "primaryKey": false, "notNull": false } @@ -3598,13 +3855,19 @@ "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": ["identifier"] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.mail_dispatch_outbox": { - "name": "mail_dispatch_outbox", + "public.ongoing_sequences": { + "name": "ongoing_sequences", "schema": "", "columns": { "id": { @@ -3613,65 +3876,46 @@ "primaryKey": true, "notNull": true }, - "dispatch_id": { - "name": "dispatch_id", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "outbound_message_id": { - "name": "outbound_message_id", + "sequence_id": { + "name": "sequence_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "queue_name": { - "name": "queue_name", - "type": "text", + "contact_id": { + "name": "contact_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "job_name": { - "name": "job_name", - "type": "text", + "next_email_scheduled_time": { + "name": "next_email_scheduled_time", + "type": "bigint", "primaryKey": false, "notNull": true }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "available_at": { - "name": "available_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "lease_expires_at": { - "name": "lease_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "publish_attempts": { - "name": "publish_attempts", + "retry_count": { + "name": "retry_count", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 }, - "last_error": { - "name": "last_error", - "type": "text", + "sent_email_ids": { + "name": "sent_email_ids", + "type": "text[]", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'{}'" }, - "published_at": { - "name": "published_at", + "processing_started_at": { + "name": "processing_started_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false @@ -3680,29 +3924,44 @@ "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, "indexes": { - "mail_dispatch_outbox_due_idx": { - "name": "mail_dispatch_outbox_due_idx", + "ongoing_sequences_sequence_id_contact_id_idx": { + "name": "ongoing_sequences_sequence_id_contact_id_idx", "columns": [ { - "expression": "state", + "expression": "sequence_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "available_at", + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ongoing_sequences_next_email_scheduled_time_idx": { + "name": "ongoing_sequences_next_email_scheduled_time_idx", + "columns": [ + { + "expression": "next_email_scheduled_time", "isExpression": false, "asc": true, "nulls": "last" @@ -3715,44 +3974,42 @@ } }, "foreignKeys": { - "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk": { - "name": "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk", - "tableFrom": "mail_dispatch_outbox", - "tableTo": "outbound_messages", - "columnsFrom": ["outbound_message_id"], + "ongoing_sequences_team_id_teams_id_fk": { + "name": "ongoing_sequences_team_id_teams_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "mail_dispatch_outbox_dispatch_id_unique": { - "name": "mail_dispatch_outbox_dispatch_id_unique", - "nullsNotDistinct": false, - "columns": ["dispatch_id"] }, - "mail_dispatch_outbox_outbound_message_id_unique": { - "name": "mail_dispatch_outbox_outbound_message_id_unique", - "nullsNotDistinct": false, - "columns": ["outbound_message_id"] - } - }, - "policies": {}, - "checkConstraints": { - "mail_dispatch_outbox_dispatch_id_check": { - "name": "mail_dispatch_outbox_dispatch_id_check", - "value": "\"mail_dispatch_outbox\".\"dispatch_id\" ~ '^mdj_'" + "ongoing_sequences_sequence_id_sequences_id_fk": { + "name": "ongoing_sequences_sequence_id_sequences_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" }, - "mail_dispatch_outbox_state_check": { - "name": "mail_dispatch_outbox_state_check", - "value": "\"mail_dispatch_outbox\".\"state\" IN ('pending', 'publishing', 'published', 'cancelled')" + "ongoing_sequences_contact_id_contacts_id_fk": { + "name": "ongoing_sequences_contact_id_contacts_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.media": { - "name": "media", + "public.organization_api_keys": { + "name": "organization_api_keys", "schema": "", "columns": { "id": { @@ -3761,74 +4018,63 @@ "primaryKey": true, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "organization_api_key_id": { + "name": "organization_api_key_id", + "type": "text", "primaryKey": false, "notNull": true }, - "media_id": { - "name": "media_id", - "type": "text", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "media_lit_id": { - "name": "media_lit_id", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true }, - "url": { - "name": "url", + "key_hash": { + "name": "key_hash", "type": "text", "primaryKey": false, "notNull": true }, - "thumbnail_url": { - "name": "thumbnail_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "mime_type": { - "name": "mime_type", + "key_prefix": { + "name": "key_prefix", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "size": { - "name": "size", - "type": "integer", + "scopes": { + "name": "scopes", + "type": "text[]", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'{}'" }, - "width": { - "name": "width", - "type": "integer", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "height": { - "name": "height", - "type": "integer", + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "alt": { - "name": "alt", - "type": "text", + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "caption": { - "name": "caption", + "created_by_user_id": { + "name": "created_by_user_id", "type": "text", "primaryKey": false, "notNull": false @@ -3837,50 +4083,16 @@ "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" } }, "indexes": { - "media_team_id_media_lit_id_idx": { - "name": "media_team_id_media_lit_id_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "media_lit_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_team_id_created_at_idx": { - "name": "media_team_id_created_at_idx", + "organization_api_keys_organization_id_idx": { + "name": "organization_api_keys_organization_id_idx", "columns": [ { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", + "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" @@ -3893,35 +4105,49 @@ } }, "foreignKeys": { - "media_team_id_teams_id_fk": { - "name": "media_team_id_teams_id_fk", - "tableFrom": "media", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "organization_api_keys_organization_id_organizations_id_fk": { + "name": "organization_api_keys_organization_id_organizations_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" + }, + "organization_api_keys_created_by_user_id_user_id_fk": { + "name": "organization_api_keys_created_by_user_id_user_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "media_media_id_unique": { - "name": "media_media_id_unique", + "organization_api_keys_organization_api_key_id_unique": { + "name": "organization_api_keys_organization_api_key_id_unique", "nullsNotDistinct": false, - "columns": ["media_id"] + "columns": ["organization_api_key_id"] + }, + "organization_api_keys_key_hash_unique": { + "name": "organization_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] } }, "policies": {}, "checkConstraints": { - "media_media_id_check": { - "name": "media_media_id_check", - "value": "\"media\".\"media_id\" ~ '^med_'" + "organization_api_keys_public_id_check": { + "name": "organization_api_keys_public_id_check", + "value": "\"organization_api_keys\".\"organization_api_key_id\" ~ '^oak_'" } }, "isRLSEnabled": false }, - "public.media_references": { - "name": "media_references", + "public.organization_audit_events": { + "name": "organization_audit_events", "schema": "", "columns": { "id": { @@ -3930,96 +4156,75 @@ "primaryKey": true, "notNull": true }, - "team_id": { - "name": "team_id", + "organization_id": { + "name": "organization_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "media_id": { - "name": "media_id", - "type": "uuid", + "actor_type": { + "name": "actor_type", + "type": "text", "primaryKey": false, "notNull": true }, - "resource_type": { - "name": "resource_type", + "actor_id": { + "name": "actor_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "resource_internal_id": { - "name": "resource_internal_id", - "type": "uuid", + "action": { + "name": "action", + "type": "text", "primaryKey": false, "notNull": true }, - "resource_public_id": { - "name": "resource_public_id", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, - "parent_resource_internal_id": { - "name": "parent_resource_internal_id", + "esp_config_id": { + "name": "esp_config_id", "type": "uuid", "primaryKey": false, "notNull": false }, - "parent_resource_public_id": { - "name": "parent_resource_public_id", - "type": "text", + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "metadata": { + "name": "metadata", + "type": "jsonb", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true, + "default": "'{}'::jsonb" }, - "updated_at": { - "name": "updated_at", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" } }, "indexes": { - "media_references_resource_idx": { - "name": "media_references_resource_idx", + "organization_audit_events_organization_id_created_at_idx": { + "name": "organization_audit_events_organization_id_created_at_idx", "columns": [ { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "resource_type", + "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "resource_internal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "media_references_media_id_idx": { - "name": "media_references_media_id_idx", - "columns": [ - { - "expression": "media_id", + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" @@ -4030,8 +4235,8 @@ "method": "btree", "with": {} }, - "media_references_resource_media_idx": { - "name": "media_references_resource_media_idx", + "organization_audit_events_team_id_created_at_idx": { + "name": "organization_audit_events_team_id_created_at_idx", "columns": [ { "expression": "team_id", @@ -4040,47 +4245,26 @@ "nulls": "last" }, { - "expression": "resource_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "resource_internal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "media_id", + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "media_references_team_id_teams_id_fk": { - "name": "media_references_team_id_teams_id_fk", - "tableFrom": "media_references", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "media_references_media_id_media_id_fk": { - "name": "media_references_media_id_media_id_fk", - "tableFrom": "media_references", - "tableTo": "media", - "columnsFrom": ["media_id"], + "organization_audit_events_organization_id_organizations_id_fk": { + "name": "organization_audit_events_organization_id_organizations_id_fk", + "tableFrom": "organization_audit_events", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" } }, @@ -4090,290 +4274,181 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.oauth_access_token": { - "name": "oauth_access_token", + "public.organization_delivery_policies": { + "name": "organization_delivery_policies", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, "notNull": true }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "client_id": { - "name": "client_id", - "type": "text", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "session_id": { - "name": "session_id", - "type": "text", + "default_esp_config_id": { + "name": "default_esp_config_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "user_id": { - "name": "user_id", - "type": "text", + "auto_grant_default_esp": { + "name": "auto_grant_default_esp", + "type": "boolean", "primaryKey": false, - "notNull": false + "notNull": true, + "default": false }, - "reference_id": { - "name": "reference_id", - "type": "text", + "default_daily_limit": { + "name": "default_daily_limit", + "type": "integer", "primaryKey": false, "notNull": false }, - "authorization_code_id": { - "name": "authorization_code_id", - "type": "text", + "default_monthly_limit": { + "name": "default_monthly_limit", + "type": "integer", "primaryKey": false, "notNull": false }, - "resources": { - "name": "resources", - "type": "text[]", + "aggregate_daily_limit": { + "name": "aggregate_daily_limit", + "type": "integer", "primaryKey": false, "notNull": false }, - "requested_user_info_claims": { - "name": "requested_user_info_claims", - "type": "text[]", + "aggregate_monthly_limit": { + "name": "aggregate_monthly_limit", + "type": "integer", "primaryKey": false, "notNull": false }, - "refresh_id": { - "name": "refresh_id", - "type": "text", + "team_esp_enabled_by_default": { + "name": "team_esp_enabled_by_default", + "type": "boolean", "primaryKey": false, - "notNull": false + "notNull": true, + "default": true }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", "primaryKey": false, - "notNull": true + "notNull": true, + "default": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true - }, - "scopes": { - "name": "scopes", - "type": "text[]", - "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, - "confirmation": { - "name": "confirmation", - "type": "jsonb", + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "auth_oauth_access_token_client_id_idx": { - "name": "auth_oauth_access_token_client_id_idx", - "columns": [ - { - "expression": "client_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_access_token_session_id_idx": { - "name": "auth_oauth_access_token_session_id_idx", - "columns": [ - { - "expression": "session_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_access_token_user_id_idx": { - "name": "auth_oauth_access_token_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_access_token_authorization_code_id_idx": { - "name": "auth_oauth_access_token_authorization_code_id_idx", - "columns": [ - { - "expression": "authorization_code_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_access_token_refresh_id_idx": { - "name": "auth_oauth_access_token_refresh_id_idx", - "columns": [ - { - "expression": "refresh_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} + "notNull": true, + "default": "now()" } }, + "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"], + "organization_delivery_policies_organization_id_organizations_id_fk": { + "name": "organization_delivery_policies_organization_id_organizations_id_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "organizations", + "columnsFrom": ["organization_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": "set null", + "organization_delivery_policies_default_esp_fk": { + "name": "organization_delivery_policies_default_esp_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "esp_configs", + "columnsFrom": ["default_esp_config_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "oauth_access_token_token_unique": { - "name": "oauth_access_token_token_unique", + "organization_delivery_policies_organization_id_unique": { + "name": "organization_delivery_policies_organization_id_unique", "nullsNotDistinct": false, - "columns": ["token"] + "columns": ["organization_id"] } }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "organization_delivery_policies_limit_check": { + "name": "organization_delivery_policies_limit_check", + "value": "(\"organization_delivery_policies\".\"default_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"default_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"default_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"default_monthly_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_monthly_limit\" >= 0)" + } + }, "isRLSEnabled": false }, - "public.oauth_client": { - "name": "oauth_client", + "public.organization_esp_quota_reservations": { + "name": "organization_esp_quota_reservations", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, "notNull": true }, - "client_id": { - "name": "client_id", + "reservation_id": { + "name": "reservation_id", "type": "text", "primaryKey": false, "notNull": true }, - "client_secret": { - "name": "client_secret", - "type": "text", + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "client_discovery_id": { - "name": "client_discovery_id", - "type": "text", + "grant_id": { + "name": "grant_id", + "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "disabled": { - "name": "disabled", - "type": "boolean", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, - "notNull": false, - "default": false + "notNull": true }, - "skip_consent": { - "name": "skip_consent", - "type": "boolean", + "day_period_start": { + "name": "day_period_start", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true }, - "enable_end_session": { - "name": "enable_end_session", - "type": "boolean", + "month_period_start": { + "name": "month_period_start", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true }, - "subject_type": { - "name": "subject_type", + "state": { + "name": "state", "type": "text", "primaryKey": false, - "notNull": false - }, - "scopes": { - "name": "scopes", - "type": "text[]", - "primaryKey": false, - "notNull": false - }, - "client_credentials_scopes": { - "name": "client_credentials_scopes", - "type": "text[]", - "primaryKey": false, "notNull": true, - "default": "'{}'" + "default": "'reserved'" }, - "user_id": { - "name": "user_id", + "release_reason": { + "name": "release_reason", "type": "text", "primaryKey": false, "notNull": false @@ -4382,308 +4457,310 @@ "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" }, - "updated_at": { - "name": "updated_at", + "committed_at": { + "name": "committed_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", + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk": { + "name": "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "outbound_messages", + "columnsFrom": ["outbound_message_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" }, - "policy": { - "name": "policy", - "type": "text", - "primaryKey": false, - "notNull": false + "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" }, - "software_id": { - "name": "software_id", - "type": "text", - "primaryKey": false, - "notNull": false + "organization_esp_quota_reservations_organization_id_organizations_id_fk": { + "name": "organization_esp_quota_reservations_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" }, - "software_version": { - "name": "software_version", - "type": "text", - "primaryKey": false, - "notNull": false + "organization_esp_quota_reservations_grant_organization_fk": { + "name": "organization_esp_quota_reservations_grant_organization_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_esp_quota_reservations_reservation_id_unique": { + "name": "organization_esp_quota_reservations_reservation_id_unique", + "nullsNotDistinct": false, + "columns": ["reservation_id"] }, - "software_statement": { - "name": "software_statement", - "type": "text", - "primaryKey": false, - "notNull": false + "organization_esp_quota_reservations_outbound_message_id_unique": { + "name": "organization_esp_quota_reservations_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": ["outbound_message_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organization_esp_quota_reservations_reservation_id_check": { + "name": "organization_esp_quota_reservations_reservation_id_check", + "value": "\"organization_esp_quota_reservations\".\"reservation_id\" ~ '^qrs_'" }, - "redirect_uris": { - "name": "redirect_uris", - "type": "text[]", - "primaryKey": false, + "organization_esp_quota_reservations_state_check": { + "name": "organization_esp_quota_reservations_state_check", + "value": "\"organization_esp_quota_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_usage_buckets": { + "name": "organization_esp_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, "notNull": true }, - "post_logout_redirect_uris": { - "name": "post_logout_redirect_uris", - "type": "text[]", - "primaryKey": false, - "notNull": false - }, - "backchannel_logout_uri": { - "name": "backchannel_logout_uri", + "bucket_scope": { + "name": "bucket_scope", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "backchannel_logout_session_required": { - "name": "backchannel_logout_session_required", - "type": "boolean", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "token_endpoint_auth_method": { - "name": "token_endpoint_auth_method", - "type": "text", + "grant_id": { + "name": "grant_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "application_type": { - "name": "application_type", + "period_type": { + "name": "period_type", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "jwks": { - "name": "jwks", - "type": "text", + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true }, - "jwks_uri": { - "name": "jwks_uri", - "type": "text", + "reserved_count": { + "name": "reserved_count", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 0 }, - "grant_types": { - "name": "grant_types", - "type": "text[]", + "accepted_count": { + "name": "accepted_count", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 0 }, - "response_types": { - "name": "response_types", - "type": "text[]", + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "require_pkce": { - "name": "require_pkce", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "dpop_bound_access_tokens": { - "name": "dpop_bound_access_tokens", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "reference_id": { - "name": "reference_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" } }, "indexes": { - "auth_oauth_client_user_id_idx": { - "name": "auth_oauth_client_user_id_idx", + "organization_esp_usage_buckets_grant_period_idx": { + "name": "organization_esp_usage_buckets_grant_period_idx", "columns": [ { - "expression": "user_id", + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_esp_usage_buckets_organization_period_idx": { + "name": "organization_esp_usage_buckets_organization_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization'", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "oauth_client_user_id_user_id_fk": { - "name": "oauth_client_user_id_user_id_fk", - "tableFrom": "oauth_client", - "tableTo": "user", - "columnsFrom": ["user_id"], + "organization_esp_usage_buckets_organization_id_organizations_id_fk": { + "name": "organization_esp_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_organization_fk": { + "name": "organization_esp_usage_buckets_grant_organization_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["grant_id", "organization_id"], + "columnsTo": ["id", "organization_id"], + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "oauth_client_client_id_unique": { - "name": "oauth_client_client_id_unique", - "nullsNotDistinct": false, - "columns": ["client_id"] - } - }, + "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.oauth_client_assertion": { - "name": "oauth_client_assertion", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true + "checkConstraints": { + "organization_esp_usage_buckets_scope_check": { + "name": "organization_esp_usage_buckets_scope_check", + "value": "(\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'grant' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL\n ) OR (\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NULL\n )" }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true + "organization_esp_usage_buckets_period_check": { + "name": "organization_esp_usage_buckets_period_check", + "value": "\"organization_esp_usage_buckets\".\"period_type\" IN ('day', 'month')" + }, + "organization_esp_usage_buckets_count_check": { + "name": "organization_esp_usage_buckets_count_check", + "value": "\"organization_esp_usage_buckets\".\"reserved_count\" >= 0 AND \"organization_esp_usage_buckets\".\"accepted_count\" >= 0" } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, "isRLSEnabled": false }, - "public.oauth_client_resource": { - "name": "oauth_client_resource", + "public.organization_members": { + "name": "organization_members", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, "notNull": true }, - "client_id": { - "name": "client_id", - "type": "text", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "resource_id": { - "name": "resource_id", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "metadata": { - "name": "metadata", - "type": "jsonb", + "role": { + "name": "role", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": { - "auth_oauth_client_resource_client_id_idx": { - "name": "auth_oauth_client_resource_client_id_idx", - "columns": [ - { - "expression": "client_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_client_resource_resource_id_idx": { - "name": "auth_oauth_client_resource_resource_id_idx", - "columns": [ - { - "expression": "resource_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_client_resource_client_id_resource_id_idx": { - "name": "auth_oauth_client_resource_client_id_resource_id_idx", + "organization_members_organization_id_user_id_idx": { + "name": "organization_members_organization_id_user_id_idx", "columns": [ { - "expression": "client_id", + "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "resource_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -4696,96 +4773,274 @@ } }, "foreignKeys": { - "oauth_client_resource_client_id_oauth_client_client_id_fk": { - "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", - "tableFrom": "oauth_client_resource", - "tableTo": "oauth_client", - "columnsFrom": ["client_id"], - "columnsTo": ["client_id"], + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { - "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", - "tableFrom": "oauth_client_resource", - "tableTo": "oauth_resource", - "columnsFrom": ["resource_id"], - "columnsTo": ["identifier"], - "onDelete": "cascade", + "organization_members_user_id_user_id_fk": { + "name": "organization_members_user_id_user_id_fk", + "tableFrom": "organization_members", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "organization_members_role_check": { + "name": "organization_members_role_check", + "value": "\"organization_members\".\"role\" IN ('owner', 'admin', 'member')" + } + }, "isRLSEnabled": false }, - "public.oauth_consent": { - "name": "oauth_consent", + "public.organizations": { + "name": "organizations", "schema": "", "columns": { "id": { "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_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, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_organization_id_unique": { + "name": "organizations_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + } + }, + "policies": {}, + "checkConstraints": { + "organizations_organization_id_check": { + "name": "organizations_organization_id_check", + "value": "\"organizations\".\"organization_id\" ~ '^org_'" + }, + "organizations_status_check": { + "name": "organizations_status_check", + "value": "\"organizations\".\"status\" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.outbound_messages": { + "name": "outbound_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", "primaryKey": true, "notNull": true }, - "client_id": { - "name": "client_id", + "message_id": { + "name": "message_id", "type": "text", "primaryKey": false, "notNull": true }, - "user_id": { - "name": "user_id", + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "feedback_connection_id": { + "name": "feedback_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submission_key": { + "name": "submission_key", "type": "text", "primaryKey": false, "notNull": false }, - "reference_id": { - "name": "reference_id", - "type": "text", + "campaign_delivery_id": { + "name": "campaign_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transactional_email_id": { + "name": "transactional_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rfc_message_id": { + "name": "rfc_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "feedback_status": { + "name": "feedback_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bounced_at": { + "name": "bounced_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "resources": { - "name": "resources", - "type": "text[]", + "complained_at": { + "name": "complained_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "requested_user_info_claims": { - "name": "requested_user_info_claims", - "type": "text[]", + "last_event_at": { + "name": "last_event_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "scopes": { - "name": "scopes", - "type": "text[]", - "primaryKey": false, - "notNull": true - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false, + "default": "now()" } }, "indexes": { - "auth_oauth_consent_client_id_idx": { - "name": "auth_oauth_consent_client_id_idx", + "outbound_messages_team_id_created_at_idx": { + "name": "outbound_messages_team_id_created_at_idx", "columns": [ { - "expression": "client_id", + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" @@ -4796,11 +5051,44 @@ "method": "btree", "with": {} }, - "auth_oauth_consent_user_id_idx": { - "name": "auth_oauth_consent_user_id_idx", + "outbound_messages_connection_provider_msg_idx": { + "name": "outbound_messages_connection_provider_msg_idx", "columns": [ { - "expression": "user_id", + "expression": "feedback_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_team_id_recipient_created_at_idx": { + "name": "outbound_messages_team_id_recipient_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" @@ -4813,231 +5101,189 @@ } }, "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"], + "outbound_messages_team_id_teams_id_fk": { + "name": "outbound_messages_team_id_teams_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["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"], + "outbound_messages_esp_config_id_esp_configs_id_fk": { + "name": "outbound_messages_esp_config_id_esp_configs_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_configs", + "columnsFrom": ["esp_config_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.oauth_post_login_team_selections": { - "name": "oauth_post_login_team_selections", - "schema": "", - "columns": { - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": true, - "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true + "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "oauth_post_login_team_selections_session_id_session_id_fk": { - "name": "oauth_post_login_team_selections_session_id_session_id_fk", - "tableFrom": "oauth_post_login_team_selections", - "tableTo": "session", - "columnsFrom": ["session_id"], + "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk": { + "name": "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_feedback_connections", + "columnsFrom": ["feedback_connection_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "set null", "onUpdate": "no action" }, - "oauth_post_login_team_selections_team_id_teams_id_fk": { - "name": "oauth_post_login_team_selections_team_id_teams_id_fk", - "tableFrom": "oauth_post_login_team_selections", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "outbound_messages_campaign_delivery_id_email_deliveries_id_fk": { + "name": "outbound_messages_campaign_delivery_id_email_deliveries_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "email_deliveries", + "columnsFrom": ["campaign_delivery_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_transactional_email_id_transactional_emails_id_fk": { + "name": "outbound_messages_transactional_email_id_transactional_emails_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "transactional_emails", + "columnsFrom": ["transactional_email_id"], + "columnsTo": ["id"], + "onDelete": "set null", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "outbound_messages_message_id_unique": { + "name": "outbound_messages_message_id_unique", + "nullsNotDistinct": false, + "columns": ["message_id"] + }, + "outbound_messages_submission_key_unique": { + "name": "outbound_messages_submission_key_unique", + "nullsNotDistinct": false, + "columns": ["submission_key"] + } + }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "outbound_messages_message_id_check": { + "name": "outbound_messages_message_id_check", + "value": "\"outbound_messages\".\"message_id\" ~ '^msg_'" + }, + "outbound_messages_delivery_pin_check": { + "name": "outbound_messages_delivery_pin_check", + "value": "(\n \"outbound_messages\".\"delivery_source_type\" = 'team'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" = 'organization'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NOT NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" IN ('team', 'organization')\n AND \"outbound_messages\".\"esp_config_id\" IS NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n AND \"outbound_messages\".\"delivery_status\" <> 'queued'\n )" + } + }, "isRLSEnabled": false }, - "public.oauth_refresh_token": { - "name": "oauth_refresh_token", + "public.plan_send_reservations": { + "name": "plan_send_reservations", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "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 - }, - "authorization_code_id": { - "name": "authorization_code_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "resources": { - "name": "resources", - "type": "text[]", - "primaryKey": false, - "notNull": false - }, - "requested_user_info_claims": { - "name": "requested_user_info_claims", - "type": "text[]", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "revoked": { - "name": "revoked", - "type": "timestamp with time zone", + "bucket_id": { + "name": "bucket_id", + "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "rotated_at": { - "name": "rotated_at", - "type": "timestamp with time zone", + "amount": { + "name": "amount", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 1 }, - "rotation_replay_response": { - "name": "rotation_replay_response", + "state": { + "name": "state", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'reserved'" }, - "rotation_replay_expires_at": { - "name": "rotation_replay_expires_at", + "expires_at": { + "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true }, - "auth_time": { - "name": "auth_time", + "committed_at": { + "name": "committed_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "confirmation": { - "name": "confirmation", - "type": "jsonb", + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "scopes": { - "name": "scopes", - "type": "text[]", + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": { - "auth_oauth_refresh_token_client_id_idx": { - "name": "auth_oauth_refresh_token_client_id_idx", + "plan_send_reservations_outbound_uidx": { + "name": "plan_send_reservations_outbound_uidx", "columns": [ { - "expression": "client_id", + "expression": "outbound_message_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} }, - "auth_oauth_refresh_token_authorization_code_id_idx": { - "name": "auth_oauth_refresh_token_authorization_code_id_idx", + "plan_send_reservations_expiry_idx": { + "name": "plan_send_reservations_expiry_idx", "columns": [ { - "expression": "authorization_code_id", + "expression": "state", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "auth_oauth_refresh_token_session_id_idx": { - "name": "auth_oauth_refresh_token_session_id_idx", - "columns": [ + }, { - "expression": "session_id", + "expression": "expires_at", "isExpression": false, "asc": true, "nulls": "last" @@ -5047,178 +5293,240 @@ "concurrently": false, "method": "btree", "with": {} + } + }, + "foreignKeys": { + "plan_send_reservations_organization_id_organizations_id_fk": { + "name": "plan_send_reservations_organization_id_organizations_id_fk", + "tableFrom": "plan_send_reservations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" }, - "auth_oauth_refresh_token_user_id_idx": { - "name": "auth_oauth_refresh_token_user_id_idx", + "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk": { + "name": "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk", + "tableFrom": "plan_send_reservations", + "tableTo": "plan_send_usage_buckets", + "columnsFrom": ["bucket_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plan_send_reservations_amount_check": { + "name": "plan_send_reservations_amount_check", + "value": "\"plan_send_reservations\".\"amount\" > 0" + }, + "plan_send_reservations_state_check": { + "name": "plan_send_reservations_state_check", + "value": "\"plan_send_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.plan_send_usage_buckets": { + "name": "plan_send_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket_month": { + "name": "bucket_month", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "committed": { + "name": "committed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reserved": { + "name": "reserved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_send_usage_buckets_organization_month_uidx": { + "name": "plan_send_usage_buckets_organization_month_uidx", "columns": [ { - "expression": "user_id", + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_month", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "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"], + "plan_send_usage_buckets_organization_id_organizations_id_fk": { + "name": "plan_send_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "plan_send_usage_buckets", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "oauth_refresh_token_token_unique": { - "name": "oauth_refresh_token_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plan_send_usage_buckets_count_check": { + "name": "plan_send_usage_buckets_count_check", + "value": "\"plan_send_usage_buckets\".\"committed\" >= 0 AND \"plan_send_usage_buckets\".\"reserved\" >= 0" } }, - "policies": {}, - "checkConstraints": {}, "isRLSEnabled": false }, - "public.oauth_resource": { - "name": "oauth_resource", + "public.rules": { + "name": "rules", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, "notNull": true }, - "identifier": { - "name": "identifier", + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "event": { + "name": "event", "type": "text", "primaryKey": false, "notNull": true }, - "access_token_ttl": { - "name": "access_token_ttl", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "refresh_token_ttl": { - "name": "refresh_token_ttl", - "type": "integer", + "sequence_id": { + "name": "sequence_id", + "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "signing_algorithm": { - "name": "signing_algorithm", - "type": "text", + "event_date_in_millis": { + "name": "event_date_in_millis", + "type": "bigint", "primaryKey": false, "notNull": false }, - "signing_key_id": { - "name": "signing_key_id", + "event_data": { + "name": "event_data", "type": "text", "primaryKey": false, "notNull": false }, - "allowed_scopes": { - "name": "allowed_scopes", - "type": "text[]", - "primaryKey": false, - "notNull": false - }, - "custom_claims": { - "name": "custom_claims", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "dpop_bound_access_tokens_required": { - "name": "dpop_bound_access_tokens_required", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "disabled": { - "name": "disabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": false, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false - }, - "policy_version": { - "name": "policy_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false + "notNull": false, + "default": "now()" } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "rules_team_id_teams_id_fk": { + "name": "rules_team_id_teams_id_fk", + "tableFrom": "rules", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rules_sequence_id_sequences_id_fk": { + "name": "rules_sequence_id_sequences_id_fk", + "tableFrom": "rules", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "oauth_resource_identifier_unique": { - "name": "oauth_resource_identifier_unique", + "rules_rule_id_unique": { + "name": "rules_rule_id_unique", "nullsNotDistinct": false, - "columns": ["identifier"] + "columns": ["rule_id"] } }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "rules_rule_id_check": { + "name": "rules_rule_id_check", + "value": "\"rules\".\"rule_id\" ~ '^rule_'" + } + }, "isRLSEnabled": false }, - "public.ongoing_sequences": { - "name": "ongoing_sequences", + "public.segments": { + "name": "segments", "schema": "", "columns": { "id": { @@ -5233,44 +5541,24 @@ "primaryKey": false, "notNull": true }, - "sequence_id": { - "name": "sequence_id", - "type": "uuid", + "segment_id": { + "name": "segment_id", + "type": "text", "primaryKey": false, "notNull": true }, - "contact_id": { - "name": "contact_id", - "type": "uuid", + "name": { + "name": "name", + "type": "text", "primaryKey": false, "notNull": true }, - "next_email_scheduled_time": { - "name": "next_email_scheduled_time", - "type": "bigint", + "filter": { + "name": "filter", + "type": "jsonb", "primaryKey": false, "notNull": true }, - "retry_count": { - "name": "retry_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "sent_email_ids": { - "name": "sent_email_ids", - "type": "text[]", - "primaryKey": false, - "notNull": true, - "default": "'{}'" - }, - "processing_started_at": { - "name": "processing_started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -5287,17 +5575,17 @@ } }, "indexes": { - "ongoing_sequences_sequence_id_contact_id_idx": { - "name": "ongoing_sequences_sequence_id_contact_id_idx", + "segments_team_id_name_idx": { + "name": "segments_team_id_name_idx", "columns": [ { - "expression": "sequence_id", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "contact_id", + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" @@ -5307,60 +5595,38 @@ "concurrently": false, "method": "btree", "with": {} - }, - "ongoing_sequences_next_email_scheduled_time_idx": { - "name": "ongoing_sequences_next_email_scheduled_time_idx", - "columns": [ - { - "expression": "next_email_scheduled_time", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { - "ongoing_sequences_team_id_teams_id_fk": { - "name": "ongoing_sequences_team_id_teams_id_fk", - "tableFrom": "ongoing_sequences", + "segments_team_id_teams_id_fk": { + "name": "segments_team_id_teams_id_fk", + "tableFrom": "segments", "tableTo": "teams", "columnsFrom": ["team_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" - }, - "ongoing_sequences_sequence_id_sequences_id_fk": { - "name": "ongoing_sequences_sequence_id_sequences_id_fk", - "tableFrom": "ongoing_sequences", - "tableTo": "sequences", - "columnsFrom": ["sequence_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "ongoing_sequences_contact_id_contacts_id_fk": { - "name": "ongoing_sequences_contact_id_contacts_id_fk", - "tableFrom": "ongoing_sequences", - "tableTo": "contacts", - "columnsFrom": ["contact_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "segments_segment_id_unique": { + "name": "segments_segment_id_unique", + "nullsNotDistinct": false, + "columns": ["segment_id"] + } + }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "segments_segment_id_check": { + "name": "segments_segment_id_check", + "value": "\"segments\".\"segment_id\" ~ '^seg_'" + } + }, "isRLSEnabled": false }, - "public.organization_api_keys": { - "name": "organization_api_keys", + "public.sending_domains": { + "name": "sending_domains", "schema": "", "columns": { "id": { @@ -5369,8 +5635,8 @@ "primaryKey": true, "notNull": true }, - "organization_api_key_id": { - "name": "organization_api_key_id", + "domain_id": { + "name": "domain_id", "type": "text", "primaryKey": false, "notNull": true @@ -5381,52 +5647,53 @@ "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "domain": { + "name": "domain", "type": "text", "primaryKey": false, "notNull": true }, - "key_hash": { - "name": "key_hash", + "challenge_token_hash": { + "name": "challenge_token_hash", "type": "text", "primaryKey": false, "notNull": true }, - "key_prefix": { - "name": "key_prefix", + "status": { + "name": "status", "type": "text", "primaryKey": false, - "notNull": true - }, - "scopes": { - "name": "scopes", - "type": "text[]", - "primaryKey": false, "notNull": true, - "default": "'{}'" + "default": "'pending'" }, - "expires_at": { - "name": "expires_at", + "verified_at": { + "name": "verified_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "last_used_at": { - "name": "last_used_at", + "last_checked_at": { + "name": "last_checked_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "revoked_at": { - "name": "revoked_at", + "next_check_at": { + "name": "next_check_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "created_by_user_id": { - "name": "created_by_user_id", - "type": "text", + "failed_check_count": { + "name": "failed_check_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failed_at": { + "name": "first_failed_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, @@ -5436,69 +5703,76 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": { - "organization_api_keys_organization_id_idx": { - "name": "organization_api_keys_organization_id_idx", + "sending_domains_organization_domain_uidx": { + "name": "sending_domains_organization_domain_uidx", "columns": [ { "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "organization_api_keys_organization_id_organizations_id_fk": { - "name": "organization_api_keys_organization_id_organizations_id_fk", - "tableFrom": "organization_api_keys", + "sending_domains_organization_id_organizations_id_fk": { + "name": "sending_domains_organization_id_organizations_id_fk", + "tableFrom": "sending_domains", "tableTo": "organizations", "columnsFrom": ["organization_id"], "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "organization_api_keys_created_by_user_id_user_id_fk": { - "name": "organization_api_keys_created_by_user_id_user_id_fk", - "tableFrom": "organization_api_keys", - "tableTo": "user", - "columnsFrom": ["created_by_user_id"], - "columnsTo": ["id"], - "onDelete": "set null", + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "organization_api_keys_organization_api_key_id_unique": { - "name": "organization_api_keys_organization_api_key_id_unique", - "nullsNotDistinct": false, - "columns": ["organization_api_key_id"] - }, - "organization_api_keys_key_hash_unique": { - "name": "organization_api_keys_key_hash_unique", + "sending_domains_domain_id_unique": { + "name": "sending_domains_domain_id_unique", "nullsNotDistinct": false, - "columns": ["key_hash"] + "columns": ["domain_id"] } }, "policies": {}, "checkConstraints": { - "organization_api_keys_public_id_check": { - "name": "organization_api_keys_public_id_check", - "value": "\"organization_api_keys\".\"organization_api_key_id\" ~ '^oak_'" + "sending_domains_domain_id_check": { + "name": "sending_domains_domain_id_check", + "value": "\"sending_domains\".\"domain_id\" ~ '^domain_'" + }, + "sending_domains_status_check": { + "name": "sending_domains_status_check", + "value": "\"sending_domains\".\"status\" IN ('pending', 'verified', 'revoked', 'failed')" + }, + "sending_domains_failed_check_count_check": { + "name": "sending_domains_failed_check_count_check", + "value": "\"sending_domains\".\"failed_check_count\" >= 0" } }, "isRLSEnabled": false }, - "public.organization_audit_events": { - "name": "organization_audit_events", + "public.sequence_emails": { + "name": "sequence_emails", "schema": "", "columns": { "id": { @@ -5507,126 +5781,124 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", + "sequence_id": { + "name": "sequence_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "actor_type": { - "name": "actor_type", + "email_id": { + "name": "email_id", "type": "text", "primaryKey": false, "notNull": true }, - "actor_id": { - "name": "actor_id", + "subject": { + "name": "subject", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "action": { - "name": "action", - "type": "text", + "content": { + "name": "content", + "type": "jsonb", "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "delay_in_millis": { + "name": "delay_in_millis", + "type": "bigint", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 86400000 }, - "esp_config_id": { - "name": "esp_config_id", - "type": "uuid", + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "text", "primaryKey": false, "notNull": false }, - "esp_grant_id": { - "name": "esp_grant_id", - "type": "uuid", + "action_type": { + "name": "action_type", + "type": "text", "primaryKey": false, "notNull": false }, - "metadata": { - "name": "metadata", + "action_data": { + "name": "action_data", "type": "jsonb", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, "default": "now()" } }, "indexes": { - "organization_audit_events_organization_id_created_at_idx": { - "name": "organization_audit_events_organization_id_created_at_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "organization_audit_events_team_id_created_at_idx": { - "name": "organization_audit_events_team_id_created_at_idx", + "sequence_emails_sequence_id_email_id_idx": { + "name": "sequence_emails_sequence_id_email_id_idx", "columns": [ { - "expression": "team_id", + "expression": "sequence_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", + "expression": "email_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "organization_audit_events_organization_id_organizations_id_fk": { - "name": "organization_audit_events_organization_id_organizations_id_fk", - "tableFrom": "organization_audit_events", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "sequence_emails_sequence_id_sequences_id_fk": { + "name": "sequence_emails_sequence_id_sequences_id_fk", + "tableFrom": "sequence_emails", + "tableTo": "sequences", + "columnsFrom": ["sequence_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "sequence_emails_email_id_check": { + "name": "sequence_emails_email_id_check", + "value": "\"sequence_emails\".\"email_id\" ~ '^email_'" + } + }, "isRLSEnabled": false }, - "public.organization_delivery_policies": { - "name": "organization_delivery_policies", + "public.sequences": { + "name": "sequences", "schema": "", "columns": { "id": { @@ -5635,430 +5907,329 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "default_esp_config_id": { - "name": "default_esp_config_id", + "sequence_id": { + "name": "sequence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "delivery_source_intent": { + "name": "delivery_source_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbox_id": { + "name": "outbox_id", "type": "uuid", "primaryKey": false, "notNull": false }, - "auto_grant_default_esp": { - "name": "auto_grant_default_esp", - "type": "boolean", + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", "primaryKey": false, - "notNull": true, - "default": false + "notNull": false }, - "default_daily_limit": { - "name": "default_daily_limit", - "type": "integer", + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_data": { + "name": "trigger_data", + "type": "text", "primaryKey": false, "notNull": false }, - "default_monthly_limit": { - "name": "default_monthly_limit", - "type": "integer", + "filter": { + "name": "filter", + "type": "jsonb", "primaryKey": false, "notNull": false }, - "aggregate_daily_limit": { - "name": "aggregate_daily_limit", - "type": "integer", + "exclude_filter": { + "name": "exclude_filter", + "type": "jsonb", "primaryKey": false, "notNull": false }, - "aggregate_monthly_limit": { - "name": "aggregate_monthly_limit", - "type": "integer", + "emails_order": { + "name": "emails_order", + "type": "text[]", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'{}'" }, - "team_esp_enabled_by_default": { - "name": "team_esp_enabled_by_default", - "type": "boolean", + "entrants": { + "name": "entrants", + "type": "text[]", "primaryKey": false, "notNull": true, - "default": true + "default": "'{}'" }, - "team_can_change_default": { - "name": "team_can_change_default", - "type": "boolean", + "report": { + "name": "report", + "type": "jsonb", "primaryKey": false, "notNull": true, - "default": true + "default": "'{}'::jsonb" }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, "indexes": {}, "foreignKeys": { - "organization_delivery_policies_organization_id_organizations_id_fk": { - "name": "organization_delivery_policies_organization_id_organizations_id_fk", - "tableFrom": "organization_delivery_policies", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "sequences_team_id_teams_id_fk": { + "name": "sequences_team_id_teams_id_fk", + "tableFrom": "sequences", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "organization_delivery_policies_default_esp_fk": { - "name": "organization_delivery_policies_default_esp_fk", - "tableFrom": "organization_delivery_policies", + "sequences_outbox_id_esp_configs_id_fk": { + "name": "sequences_outbox_id_esp_configs_id_fk", + "tableFrom": "sequences", "tableTo": "esp_configs", - "columnsFrom": ["default_esp_config_id", "organization_id"], - "columnsTo": ["id", "organization_id"], + "columnsFrom": ["outbox_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sequences_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "sequences_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "organization_delivery_policies_organization_id_unique": { - "name": "organization_delivery_policies_organization_id_unique", + "sequences_sequence_id_unique": { + "name": "sequences_sequence_id_unique", "nullsNotDistinct": false, - "columns": ["organization_id"] + "columns": ["sequence_id"] } }, "policies": {}, "checkConstraints": { - "organization_delivery_policies_limit_check": { - "name": "organization_delivery_policies_limit_check", - "value": "(\"organization_delivery_policies\".\"default_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"default_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"default_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"default_monthly_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_monthly_limit\" >= 0)" + "sequences_sequence_id_check": { + "name": "sequences_sequence_id_check", + "value": "\"sequences\".\"sequence_id\" ~ '^seq_'" + }, + "sequences_delivery_pin_check": { + "name": "sequences_delivery_pin_check", + "value": "(\n \"sequences\".\"delivery_source_type\" IS NULL\n AND \"sequences\".\"outbox_id\" IS NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'team'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'organization'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NOT NULL\n )" } }, "isRLSEnabled": false }, - "public.organization_esp_quota_reservations": { - "name": "organization_esp_quota_reservations", + "public.session": { + "name": "session", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "reservation_id": { - "name": "reservation_id", "type": "text", - "primaryKey": false, - "notNull": true - }, - "outbound_message_id": { - "name": "outbound_message_id", - "type": "uuid", - "primaryKey": false, + "primaryKey": true, "notNull": true }, - "grant_id": { - "name": "grant_id", - "type": "uuid", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "uuid", + "token": { + "name": "token", + "type": "text", "primaryKey": false, "notNull": true }, - "day_period_start": { - "name": "day_period_start", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, - "month_period_start": { - "name": "month_period_start", + "updated_at": { + "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'reserved'" - }, - "release_reason": { - "name": "release_reason", + "ip_address": { + "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "committed_at": { - "name": "committed_at", - "type": "timestamp with time zone", + "user_agent": { + "name": "user_agent", + "type": "text", "primaryKey": false, "notNull": false }, - "released_at": { - "name": "released_at", - "type": "timestamp with time zone", + "user_id": { + "name": "user_id", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true + } + }, + "indexes": { + "auth_session_user_id_idx": { + "name": "auth_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, - "indexes": {}, "foreignKeys": { - "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk": { - "name": "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk", - "tableFrom": "organization_esp_quota_reservations", - "tableTo": "outbound_messages", - "columnsFrom": ["outbound_message_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk": { - "name": "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk", - "tableFrom": "organization_esp_quota_reservations", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["grant_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_esp_quota_reservations_organization_id_organizations_id_fk": { - "name": "organization_esp_quota_reservations_organization_id_organizations_id_fk", - "tableFrom": "organization_esp_quota_reservations", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_esp_quota_reservations_grant_organization_fk": { - "name": "organization_esp_quota_reservations_grant_organization_fk", - "tableFrom": "organization_esp_quota_reservations", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["grant_id", "organization_id"], - "columnsTo": ["id", "organization_id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "organization_esp_quota_reservations_reservation_id_unique": { - "name": "organization_esp_quota_reservations_reservation_id_unique", - "nullsNotDistinct": false, - "columns": ["reservation_id"] - }, - "organization_esp_quota_reservations_outbound_message_id_unique": { - "name": "organization_esp_quota_reservations_outbound_message_id_unique", + "session_token_unique": { + "name": "session_token_unique", "nullsNotDistinct": false, - "columns": ["outbound_message_id"] + "columns": ["token"] } }, "policies": {}, - "checkConstraints": { - "organization_esp_quota_reservations_reservation_id_check": { - "name": "organization_esp_quota_reservations_reservation_id_check", - "value": "\"organization_esp_quota_reservations\".\"reservation_id\" ~ '^qrs_'" - }, - "organization_esp_quota_reservations_state_check": { - "name": "organization_esp_quota_reservations_state_check", - "value": "\"organization_esp_quota_reservations\".\"state\" IN ('reserved', 'committed', 'released')" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.organization_esp_usage_buckets": { - "name": "organization_esp_usage_buckets", + "public.settings": { + "name": "settings", "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "bucket_scope": { - "name": "bucket_scope", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "organization_id": { - "name": "organization_id", + "columns": { + "id": { + "name": "id", "type": "uuid", - "primaryKey": false, + "primaryKey": true, "notNull": true }, - "grant_id": { - "name": "grant_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "period_type": { - "name": "period_type", + "mailing_address": { + "name": "mailing_address", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "period_start": { - "name": "period_start", + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true - }, - "reserved_count": { - "name": "reserved_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "accepted_count": { - "name": "accepted_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, - "indexes": { - "organization_esp_usage_buckets_grant_period_idx": { - "name": "organization_esp_usage_buckets_grant_period_idx", - "columns": [ - { - "expression": "grant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "period_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "period_start", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "organization_esp_usage_buckets_organization_period_idx": { - "name": "organization_esp_usage_buckets_organization_period_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "period_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "period_start", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization'", - "concurrently": false, - "method": "btree", - "with": {} - } - }, + "indexes": {}, "foreignKeys": { - "organization_esp_usage_buckets_organization_id_organizations_id_fk": { - "name": "organization_esp_usage_buckets_organization_id_organizations_id_fk", - "tableFrom": "organization_esp_usage_buckets", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk": { - "name": "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk", - "tableFrom": "organization_esp_usage_buckets", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["grant_id"], + "settings_team_id_teams_id_fk": { + "name": "settings_team_id_teams_id_fk", + "tableFrom": "settings", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_esp_usage_buckets_grant_organization_fk": { - "name": "organization_esp_usage_buckets_grant_organization_fk", - "tableFrom": "organization_esp_usage_buckets", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["grant_id", "organization_id"], - "columnsTo": ["id", "organization_id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "organization_esp_usage_buckets_scope_check": { - "name": "organization_esp_usage_buckets_scope_check", - "value": "(\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'grant' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL\n ) OR (\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NULL\n )" - }, - "organization_esp_usage_buckets_period_check": { - "name": "organization_esp_usage_buckets_period_check", - "value": "\"organization_esp_usage_buckets\".\"period_type\" IN ('day', 'month')" - }, - "organization_esp_usage_buckets_count_check": { - "name": "organization_esp_usage_buckets_count_check", - "value": "\"organization_esp_usage_buckets\".\"reserved_count\" >= 0 AND \"organization_esp_usage_buckets\".\"accepted_count\" >= 0" + "uniqueConstraints": { + "settings_team_id_unique": { + "name": "settings_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] } }, + "policies": {}, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.organization_members": { - "name": "organization_members", + "public.team_api_keys": { + "name": "team_api_keys", "schema": "", "columns": { "id": { @@ -6067,33 +6238,69 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", + "team_api_key_id": { + "name": "team_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "user_id": { - "name": "user_id", + "key_hash": { + "name": "key_hash", "type": "text", "primaryKey": false, "notNull": true }, - "role": { - "name": "role", + "key_prefix": { + "name": "key_prefix", "type": "text", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", "type": "timestamp with time zone", "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, "notNull": true, - "default": "now()" + "default": "'user'" }, - "updated_at": { - "name": "updated_at", + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, @@ -6101,61 +6308,61 @@ } }, "indexes": { - "organization_members_organization_id_user_id_idx": { - "name": "organization_members_organization_id_user_id_idx", + "team_api_keys_team_id_idx": { + "name": "team_api_keys_team_id_idx", "columns": [ { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "organization_members_organization_id_organizations_id_fk": { - "name": "organization_members_organization_id_organizations_id_fk", - "tableFrom": "organization_members", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "team_api_keys_team_id_teams_id_fk": { + "name": "team_api_keys_team_id_teams_id_fk", + "tableFrom": "team_api_keys", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_api_keys_team_api_key_id_unique": { + "name": "team_api_keys_team_api_key_id_unique", + "nullsNotDistinct": false, + "columns": ["team_api_key_id"] }, - "organization_members_user_id_user_id_fk": { - "name": "organization_members_user_id_user_id_fk", - "tableFrom": "organization_members", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" + "team_api_keys_key_hash_unique": { + "name": "team_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] } }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "organization_members_role_check": { - "name": "organization_members_role_check", - "value": "\"organization_members\".\"role\" IN ('owner', 'admin', 'member')" + "team_api_keys_public_id_check": { + "name": "team_api_keys_public_id_check", + "value": "\"team_api_keys\".\"team_api_key_id\" ~ '^tak_'" + }, + "team_api_keys_created_by_type_check": { + "name": "team_api_keys_created_by_type_check", + "value": "\"team_api_keys\".\"created_by_type\" IN ('user', 'organization_key', 'system')" } }, "isRLSEnabled": false }, - "public.organization_plan_states": { - "name": "organization_plan_states", + "public.team_delivery_settings": { + "name": "team_delivery_settings", "schema": "", "columns": { "id": { @@ -6164,67 +6371,35 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "plan": { - "name": "plan", - "type": "text", + "team_esp_enabled": { + "name": "team_esp_enabled", + "type": "boolean", "primaryKey": false, "notNull": true, - "default": "'free'" - }, - "active_subscription_id": { - "name": "active_subscription_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "teams_limit_override": { - "name": "teams_limit_override", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "contacts_limit_override": { - "name": "contacts_limit_override", - "type": "integer", - "primaryKey": false, - "notNull": false + "default": true }, - "projection_version": { - "name": "projection_version", - "type": "integer", + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", "primaryKey": false, "notNull": true, - "default": 0 + "default": true }, - "first_paid_activated_at": { - "name": "first_paid_activated_at", - "type": "timestamp with time zone", + "default_source": { + "name": "default_source", + "type": "text", "primaryKey": false, "notNull": false }, - "ramp_stage": { - "name": "ramp_stage", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "ramp_clean_stage_days": { - "name": "ramp_clean_stage_days", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "ramp_evaluated_at": { - "name": "ramp_evaluated_at", - "type": "timestamp with time zone", + "default_team_esp_config_id": { + "name": "default_team_esp_config_id", + "type": "uuid", "primaryKey": false, "notNull": false }, @@ -6245,60 +6420,44 @@ }, "indexes": {}, "foreignKeys": { - "organization_plan_states_organization_id_organizations_id_fk": { - "name": "organization_plan_states_organization_id_organizations_id_fk", - "tableFrom": "organization_plan_states", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "team_delivery_settings_team_id_teams_id_fk": { + "name": "team_delivery_settings_team_id_teams_id_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "organization_plan_states_active_subscription_id_organization_subscriptions_id_fk": { - "name": "organization_plan_states_active_subscription_id_organization_subscriptions_id_fk", - "tableFrom": "organization_plan_states", - "tableTo": "organization_subscriptions", - "columnsFrom": ["active_subscription_id"], - "columnsTo": ["id"], + "team_delivery_settings_default_team_esp_fk": { + "name": "team_delivery_settings_default_team_esp_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "esp_configs", + "columnsFrom": ["default_team_esp_config_id", "team_id"], + "columnsTo": ["id", "team_id"], "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "organization_plan_states_organization_id_unique": { - "name": "organization_plan_states_organization_id_unique", + "team_delivery_settings_team_id_unique": { + "name": "team_delivery_settings_team_id_unique", "nullsNotDistinct": false, - "columns": ["organization_id"] + "columns": ["team_id"] } }, "policies": {}, "checkConstraints": { - "organization_plan_states_plan_check": { - "name": "organization_plan_states_plan_check", - "value": "\"organization_plan_states\".\"plan\" IN ('free', 'pro', 'business')" - }, - "organization_plan_states_teams_override_check": { - "name": "organization_plan_states_teams_override_check", - "value": "\"organization_plan_states\".\"teams_limit_override\" IS NULL OR \"organization_plan_states\".\"teams_limit_override\" > 0" - }, - "organization_plan_states_contacts_override_check": { - "name": "organization_plan_states_contacts_override_check", - "value": "\"organization_plan_states\".\"contacts_limit_override\" IS NULL OR \"organization_plan_states\".\"contacts_limit_override\" > 0" - }, - "organization_plan_states_ramp_stage_check": { - "name": "organization_plan_states_ramp_stage_check", - "value": "\"organization_plan_states\".\"ramp_stage\" BETWEEN 0 AND 3" - }, - "organization_plan_states_ramp_clean_days_check": { - "name": "organization_plan_states_ramp_clean_days_check", - "value": "\"organization_plan_states\".\"ramp_clean_stage_days\" >= 0" + "team_delivery_settings_default_source_check": { + "name": "team_delivery_settings_default_source_check", + "value": "\"team_delivery_settings\".\"default_source\" IS NULL OR \"team_delivery_settings\".\"default_source\" IN ('organization', 'team')" } }, "isRLSEnabled": false }, - "public.organization_subscriptions": { - "name": "organization_subscriptions", + "public.team_members": { + "name": "team_members", "schema": "", "columns": { "id": { @@ -6307,134 +6466,24 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "billing_customer_id": { - "name": "billing_customer_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "billing_manager_user_id": { - "name": "billing_manager_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_subscription_id": { - "name": "provider_subscription_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_product_id": { - "name": "provider_product_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "billing_price_entry_id": { - "name": "billing_price_entry_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "catalog_key": { - "name": "catalog_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plan": { - "name": "plan", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "billing_interval": { - "name": "billing_interval", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "status": { - "name": "status", + "role": { + "name": "role", "type": "text", "primaryKey": false, "notNull": true, - "default": "'pending'" - }, - "current_period_starts_at": { - "name": "current_period_starts_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "current_period_ends_at": { - "name": "current_period_ends_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "paid_through_at": { - "name": "paid_through_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "trial_ends_at": { - "name": "trial_ends_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "past_due_at": { - "name": "past_due_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "grace_ends_at": { - "name": "grace_ends_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "cancel_at_period_end": { - "name": "cancel_at_period_end", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_entitlement_source": { - "name": "is_entitlement_source", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "last_provider_event_at": { - "name": "last_provider_event_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_reconciled_at": { - "name": "last_reconciled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "default": "'member'" }, "created_at": { "name": "created_at", @@ -6452,77 +6501,43 @@ } }, "indexes": { - "organization_subscriptions_provider_subscription_uidx": { - "name": "organization_subscriptions_provider_subscription_uidx", + "team_members_team_id_user_id_idx": { + "name": "team_members_team_id_user_id_idx", "columns": [ { - "expression": "provider", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "provider_subscription_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "organization_subscriptions_organization_source_uidx": { - "name": "organization_subscriptions_organization_source_uidx", - "columns": [ - { - "expression": "organization_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, - "where": "\"organization_subscriptions\".\"is_entitlement_source\" = true", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "organization_subscriptions_organization_id_organizations_id_fk": { - "name": "organization_subscriptions_organization_id_organizations_id_fk", - "tableFrom": "organization_subscriptions", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_subscriptions_billing_customer_id_billing_provider_customers_id_fk": { - "name": "organization_subscriptions_billing_customer_id_billing_provider_customers_id_fk", - "tableFrom": "organization_subscriptions", - "tableTo": "billing_provider_customers", - "columnsFrom": ["billing_customer_id"], + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": ["team_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "cascade", "onUpdate": "no action" }, - "organization_subscriptions_billing_manager_user_id_user_id_fk": { - "name": "organization_subscriptions_billing_manager_user_id_user_id_fk", - "tableFrom": "organization_subscriptions", + "team_members_user_id_user_id_fk": { + "name": "team_members_user_id_user_id_fk", + "tableFrom": "team_members", "tableTo": "user", - "columnsFrom": ["billing_manager_user_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "organization_subscriptions_billing_price_entry_id_billing_price_entries_id_fk": { - "name": "organization_subscriptions_billing_price_entry_id_billing_price_entries_id_fk", - "tableFrom": "organization_subscriptions", - "tableTo": "billing_price_entries", - "columnsFrom": ["billing_price_entry_id"], + "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" @@ -6532,23 +6547,15 @@ "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "organization_subscriptions_status_check": { - "name": "organization_subscriptions_status_check", - "value": "\"organization_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" - }, - "organization_subscriptions_plan_check": { - "name": "organization_subscriptions_plan_check", - "value": "\"organization_subscriptions\".\"plan\" IN ('pro', 'business')" - }, - "organization_subscriptions_interval_check": { - "name": "organization_subscriptions_interval_check", - "value": "\"organization_subscriptions\".\"billing_interval\" IN ('month', 'year')" + "team_members_role_check": { + "name": "team_members_role_check", + "value": "\"team_members\".\"role\" IN ('admin', 'member')" } }, "isRLSEnabled": false }, - "public.organizations": { - "name": "organizations", + "public.team_sending_controls": { + "name": "team_sending_controls", "schema": "", "columns": { "id": { @@ -6557,24 +6564,74 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "text", + "team_id": { + "name": "team_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "status": { + "name": "status", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "'normal'" }, - "status": { - "name": "status", + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", "type": "text", "primaryKey": false, "notNull": true, - "default": "'active'" + "default": "'automatic'" + }, + "entered_at": { + "name": "entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evaluated_at": { + "name": "evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "minimum_hold_until": { + "name": "minimum_hold_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operator_reason": { + "name": "operator_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overridden_at": { + "name": "overridden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clean_evaluation_days": { + "name": "clean_evaluation_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 }, "created_at": { "name": "created_at", @@ -6592,30 +6649,53 @@ } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "team_sending_controls_team_id_teams_id_fk": { + "name": "team_sending_controls_team_id_teams_id_fk", + "tableFrom": "team_sending_controls", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "team_sending_controls_operator_user_id_user_id_fk": { + "name": "team_sending_controls_operator_user_id_user_id_fk", + "tableFrom": "team_sending_controls", + "tableTo": "user", + "columnsFrom": ["operator_user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "organizations_organization_id_unique": { - "name": "organizations_organization_id_unique", + "team_sending_controls_team_id_unique": { + "name": "team_sending_controls_team_id_unique", "nullsNotDistinct": false, - "columns": ["organization_id"] + "columns": ["team_id"] } }, "policies": {}, "checkConstraints": { - "organizations_organization_id_check": { - "name": "organizations_organization_id_check", - "value": "\"organizations\".\"organization_id\" ~ '^org_'" + "team_sending_controls_status_check": { + "name": "team_sending_controls_status_check", + "value": "\"team_sending_controls\".\"status\" IN ('normal', 'warned', 'marketing_paused', 'all_paused')" }, - "organizations_status_check": { - "name": "organizations_status_check", - "value": "\"organizations\".\"status\" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')" + "team_sending_controls_source_check": { + "name": "team_sending_controls_source_check", + "value": "\"team_sending_controls\".\"source\" IN ('automatic', 'operator')" + }, + "team_sending_controls_clean_days_check": { + "name": "team_sending_controls_clean_days_check", + "value": "\"team_sending_controls\".\"clean_evaluation_days\" >= 0" } }, "isRLSEnabled": false }, - "public.outbound_messages": { - "name": "outbound_messages", + "public.teams": { + "name": "teams", "schema": "", "columns": { "id": { @@ -6624,310 +6704,121 @@ "primaryKey": true, "notNull": true }, - "message_id": { - "name": "message_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, "team_id": { "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "delivery_source_type": { - "name": "delivery_source_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "esp_config_id": { - "name": "esp_config_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "esp_grant_id": { - "name": "esp_grant_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "feedback_connection_id": { - "name": "feedback_connection_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", "type": "text", "primaryKey": false, "notNull": true }, - "submission_key": { - "name": "submission_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "campaign_delivery_id": { - "name": "campaign_delivery_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "transactional_email_id": { - "name": "transactional_email_id", + "organization_id": { + "name": "organization_id", "type": "uuid", "primaryKey": false, - "notNull": false - }, - "recipient_email": { - "name": "recipient_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "normalized_recipient": { - "name": "normalized_recipient", - "type": "text", - "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "rfc_message_id": { - "name": "rfc_message_id", + "external_id": { + "name": "external_id", "type": "text", "primaryKey": false, "notNull": false }, - "provider_message_id": { - "name": "provider_message_id", + "provisioning_request_hash": { + "name": "provisioning_request_hash", "type": "text", "primaryKey": false, "notNull": false }, - "delivery_status": { - "name": "delivery_status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'queued'" - }, - "feedback_status": { - "name": "feedback_status", + "name": { + "name": "name", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "accepted_at": { - "name": "accepted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "delivered_at": { - "name": "delivered_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "bounced_at": { - "name": "bounced_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "complained_at": { - "name": "complained_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "notNull": true }, - "last_event_at": { - "name": "last_event_at", - "type": "timestamp with time zone", + "status": { + "name": "status", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true, + "default": "'active'" }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" } }, "indexes": { - "outbound_messages_team_id_created_at_idx": { - "name": "outbound_messages_team_id_created_at_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "outbound_messages_connection_provider_msg_idx": { - "name": "outbound_messages_connection_provider_msg_idx", - "columns": [ - { - "expression": "feedback_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_message_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "outbound_messages_team_id_recipient_created_at_idx": { - "name": "outbound_messages_team_id_recipient_created_at_idx", + "teams_organization_id_external_id_idx": { + "name": "teams_organization_id_external_id_idx", "columns": [ { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "normalized_recipient", + "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", + "expression": "external_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, + "where": "\"teams\".\"external_id\" IS NOT NULL", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "outbound_messages_team_id_teams_id_fk": { - "name": "outbound_messages_team_id_teams_id_fk", - "tableFrom": "outbound_messages", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "outbound_messages_esp_config_id_esp_configs_id_fk": { - "name": "outbound_messages_esp_config_id_esp_configs_id_fk", - "tableFrom": "outbound_messages", - "tableTo": "esp_configs", - "columnsFrom": ["esp_config_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk": { - "name": "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk", - "tableFrom": "outbound_messages", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["esp_grant_id"], + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" - }, - "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk": { - "name": "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk", - "tableFrom": "outbound_messages", - "tableTo": "esp_feedback_connections", - "columnsFrom": ["feedback_connection_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "outbound_messages_campaign_delivery_id_email_deliveries_id_fk": { - "name": "outbound_messages_campaign_delivery_id_email_deliveries_id_fk", - "tableFrom": "outbound_messages", - "tableTo": "email_deliveries", - "columnsFrom": ["campaign_delivery_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "outbound_messages_transactional_email_id_transactional_emails_id_fk": { - "name": "outbound_messages_transactional_email_id_transactional_emails_id_fk", - "tableFrom": "outbound_messages", - "tableTo": "transactional_emails", - "columnsFrom": ["transactional_email_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "outbound_messages_message_id_unique": { - "name": "outbound_messages_message_id_unique", + "teams_team_id_unique": { + "name": "teams_team_id_unique", "nullsNotDistinct": false, - "columns": ["message_id"] + "columns": ["team_id"] }, - "outbound_messages_submission_key_unique": { - "name": "outbound_messages_submission_key_unique", + "teams_id_organization_id_unique": { + "name": "teams_id_organization_id_unique", "nullsNotDistinct": false, - "columns": ["submission_key"] + "columns": ["id", "organization_id"] } }, "policies": {}, "checkConstraints": { - "outbound_messages_message_id_check": { - "name": "outbound_messages_message_id_check", - "value": "\"outbound_messages\".\"message_id\" ~ '^msg_'" + "teams_team_id_check": { + "name": "teams_team_id_check", + "value": "\"teams\".\"team_id\" ~ '^team_'" }, - "outbound_messages_delivery_pin_check": { - "name": "outbound_messages_delivery_pin_check", - "value": "(\n \"outbound_messages\".\"delivery_source_type\" = 'team'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" = 'organization'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NOT NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" IN ('team', 'organization')\n AND \"outbound_messages\".\"esp_config_id\" IS NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n AND \"outbound_messages\".\"delivery_status\" <> 'queued'\n )" + "teams_status_check": { + "name": "teams_status_check", + "value": "\"teams\".\"status\" IN ('active', 'sending_suspended', 'archived')" } }, "isRLSEnabled": false }, - "public.plan_send_reservations": { - "name": "plan_send_reservations", + "public.transactional_emails": { + "name": "transactional_emails", "schema": "", "columns": { "id": { @@ -6936,52 +6827,146 @@ "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", + "team_id": { + "name": "team_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "outbound_message_id": { - "name": "outbound_message_id", + "txe_id": { + "name": "txe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", "type": "uuid", "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, "notNull": true }, - "bucket_id": { - "name": "bucket_id", + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false }, - "amount": { - "name": "amount", - "type": "integer", + "track_opens": { + "name": "track_opens", + "type": "boolean", "primaryKey": false, "notNull": true, - "default": 1 + "default": false }, - "state": { - "name": "state", - "type": "text", + "track_clicks": { + "name": "track_clicks", + "type": "boolean", "primaryKey": false, "notNull": true, - "default": "'reserved'" + "default": false }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "open_count": { + "name": "open_count", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "default": 0 }, - "committed_at": { - "name": "committed_at", - "type": "timestamp with time zone", + "click_count": { + "name": "click_count", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 0 }, - "released_at": { - "name": "released_at", + "sent_at": { + "name": "sent_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false @@ -6990,44 +6975,72 @@ "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, + "notNull": false, "default": "now()" } }, "indexes": { - "plan_send_reservations_outbound_uidx": { - "name": "plan_send_reservations_outbound_uidx", + "transactional_emails_team_id_idempotency_key_idx": { + "name": "transactional_emails_team_id_idempotency_key_idx", "columns": [ { - "expression": "outbound_message_id", + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, + "where": "\"transactional_emails\".\"idempotency_key\" IS NOT NULL", "concurrently": false, "method": "btree", "with": {} }, - "plan_send_reservations_expiry_idx": { - "name": "plan_send_reservations_expiry_idx", + "transactional_emails_team_id_created_at_idx": { + "name": "transactional_emails_team_id_created_at_idx", "columns": [ { - "expression": "state", + "expression": "team_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "expires_at", + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_status_idx": { + "name": "transactional_emails_team_id_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", "isExpression": false, "asc": true, "nulls": "last" @@ -7040,296 +7053,270 @@ } }, "foreignKeys": { - "plan_send_reservations_organization_id_organizations_id_fk": { - "name": "plan_send_reservations_organization_id_organizations_id_fk", - "tableFrom": "plan_send_reservations", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "transactional_emails_team_id_teams_id_fk": { + "name": "transactional_emails_team_id_teams_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "teams", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactional_emails_outbox_id_esp_configs_id_fk": { + "name": "transactional_emails_outbox_id_esp_configs_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_configs", + "columnsFrom": ["outbox_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, - "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk": { - "name": "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk", - "tableFrom": "plan_send_reservations", - "tableTo": "plan_send_usage_buckets", - "columnsFrom": ["bucket_id"], + "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_config_team_grants", + "columnsFrom": ["esp_grant_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" + }, + "transactional_emails_contact_id_contacts_id_fk": { + "name": "transactional_emails_contact_id_contacts_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "transactional_emails_txe_id_unique": { + "name": "transactional_emails_txe_id_unique", + "nullsNotDistinct": false, + "columns": ["txe_id"] + } + }, "policies": {}, "checkConstraints": { - "plan_send_reservations_amount_check": { - "name": "plan_send_reservations_amount_check", - "value": "\"plan_send_reservations\".\"amount\" > 0" + "transactional_emails_txe_id_check": { + "name": "transactional_emails_txe_id_check", + "value": "\"transactional_emails\".\"txe_id\" ~ '^txe_'" }, - "plan_send_reservations_state_check": { - "name": "plan_send_reservations_state_check", - "value": "\"plan_send_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + "transactional_emails_delivery_pin_check": { + "name": "transactional_emails_delivery_pin_check", + "value": "(\n \"transactional_emails\".\"delivery_source_type\" = 'team'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NULL\n ) OR (\n \"transactional_emails\".\"delivery_source_type\" = 'organization'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NOT NULL\n )" } }, "isRLSEnabled": false }, - "public.plan_send_usage_buckets": { - "name": "plan_send_usage_buckets", + "public.user": { + "name": "user", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", + "type": "text", "primaryKey": true, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "uuid", + "name": { + "name": "name", + "type": "text", "primaryKey": false, "notNull": true }, - "bucket_month": { - "name": "bucket_month", - "type": "timestamp with time zone", + "email": { + "name": "email", + "type": "text", "primaryKey": false, "notNull": true }, - "committed": { - "name": "committed", - "type": "integer", + "email_verified": { + "name": "email_verified", + "type": "boolean", "primaryKey": false, "notNull": true, - "default": 0 + "default": false }, - "reserved": { - "name": "reserved", - "type": "integer", + "image": { + "name": "image", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false + }, + "default_organization_id": { + "name": "default_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": true }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "plan_send_usage_buckets_organization_month_uidx": { - "name": "plan_send_usage_buckets_organization_month_uidx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "bucket_month", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} + "notNull": true } }, + "indexes": {}, "foreignKeys": { - "plan_send_usage_buckets_organization_id_organizations_id_fk": { - "name": "plan_send_usage_buckets_organization_id_organizations_id_fk", - "tableFrom": "plan_send_usage_buckets", + "user_default_organization_id_organizations_id_fk": { + "name": "user_default_organization_id_organizations_id_fk", + "tableFrom": "user", "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "columnsFrom": ["default_organization_id"], "columnsTo": ["id"], - "onDelete": "restrict", + "onDelete": "set null", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "plan_send_usage_buckets_count_check": { - "name": "plan_send_usage_buckets_count_check", - "value": "\"plan_send_usage_buckets\".\"committed\" >= 0 AND \"plan_send_usage_buckets\".\"reserved\" >= 0" + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] } }, + "policies": {}, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.rules": { - "name": "rules", + "public.verification": { + "name": "verification", "schema": "", "columns": { "id": { "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "rule_id": { - "name": "rule_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true }, - "event": { - "name": "event", + "identifier": { + "name": "identifier", "type": "text", "primaryKey": false, "notNull": true }, - "sequence_id": { - "name": "sequence_id", - "type": "uuid", + "value": { + "name": "value", + "type": "text", "primaryKey": false, "notNull": true }, - "event_date_in_millis": { - "name": "event_date_in_millis", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "event_data": { - "name": "event_data", - "type": "text", + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": false + "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": true } }, - "indexes": {}, - "foreignKeys": { - "rules_team_id_teams_id_fk": { - "name": "rules_team_id_teams_id_fk", - "tableFrom": "rules", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "rules_sequence_id_sequences_id_fk": { - "name": "rules_sequence_id_sequences_id_fk", - "tableFrom": "rules", - "tableTo": "sequences", - "columnsFrom": ["sequence_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "indexes": { + "auth_verification_identifier_idx": { + "name": "auth_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "rules_rule_id_unique": { - "name": "rules_rule_id_unique", - "nullsNotDistinct": false, - "columns": ["rule_id"] - } - }, + "uniqueConstraints": {}, "policies": {}, - "checkConstraints": { - "rules_rule_id_check": { - "name": "rules_rule_id_check", - "value": "\"rules\".\"rule_id\" ~ '^rule_'" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.segments": { - "name": "segments", + "public.billing_catalog_revision_items": { + "name": "billing_catalog_revision_items", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "gen_random_uuid()" }, - "team_id": { - "name": "team_id", + "catalog_revision_id": { + "name": "catalog_revision_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "segment_id": { - "name": "segment_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", + "offer_key": { + "name": "offer_key", "type": "text", "primaryKey": false, "notNull": true }, - "filter": { - "name": "filter", - "type": "jsonb", + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", "primaryKey": false, "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" } }, "indexes": { - "segments_team_id_name_idx": { - "name": "segments_team_id_name_idx", + "billing_catalog_revision_items_revision_key_uidx": { + "name": "billing_catalog_revision_items_revision_key_uidx", "columns": [ { - "expression": "team_id", + "expression": "catalog_revision_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "name", + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_catalog_revision_items_revision_price_uidx": { + "name": "billing_catalog_revision_items_revision_price_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_price_entry_id", "isExpression": false, "asc": true, "nulls": "last" @@ -7342,63 +7329,50 @@ } }, "foreignKeys": { - "segments_team_id_teams_id_fk": { - "name": "segments_team_id_teams_id_fk", - "tableFrom": "segments", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk": { + "name": "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_catalog_revisions", + "columnsFrom": ["catalog_revision_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" + }, + "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_price_entries", + "columnsFrom": ["billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "segments_segment_id_unique": { - "name": "segments_segment_id_unique", - "nullsNotDistinct": false, - "columns": ["segment_id"] - } - }, + "uniqueConstraints": {}, "policies": {}, - "checkConstraints": { - "segments_segment_id_check": { - "name": "segments_segment_id_check", - "value": "\"segments\".\"segment_id\" ~ '^seg_'" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, - "public.sending_domains": { - "name": "sending_domains", + "public.billing_catalog_revisions": { + "name": "billing_catalog_revisions", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, - "notNull": true - }, - "domain_id": { - "name": "domain_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": true + "notNull": true, + "default": "gen_random_uuid()" }, - "domain": { - "name": "domain", - "type": "text", + "revision": { + "name": "revision", + "type": "integer", "primaryKey": false, "notNull": true }, - "challenge_token_hash": { - "name": "challenge_token_hash", + "checkout_provider": { + "name": "checkout_provider", "type": "text", "primaryKey": false, "notNull": true @@ -7408,7 +7382,7 @@ "type": "text", "primaryKey": false, "notNull": true, - "default": "'pending'" + "default": "'pending_verification'" }, "verified_at": { "name": "verified_at", @@ -7416,27 +7390,14 @@ "primaryKey": false, "notNull": false }, - "last_checked_at": { - "name": "last_checked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "next_check_at": { - "name": "next_check_at", + "activated_at": { + "name": "activated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "failed_check_count": { - "name": "failed_check_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "first_failed_at": { - "name": "first_failed_at", + "retired_at": { + "name": "retired_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false @@ -7457,127 +7418,182 @@ } }, "indexes": { - "sending_domains_organization_domain_uidx": { - "name": "sending_domains_organization_domain_uidx", + "billing_catalog_revisions_active_provider_uidx": { + "name": "billing_catalog_revisions_active_provider_uidx", "columns": [ { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "domain", + "expression": "checkout_provider", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, + "where": "\"billing_catalog_revisions\".\"status\" = 'active'", "concurrently": false, "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "sending_domains_organization_id_organizations_id_fk": { - "name": "sending_domains_organization_id_organizations_id_fk", - "tableFrom": "sending_domains", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" + "with": {} } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { - "sending_domains_domain_id_unique": { - "name": "sending_domains_domain_id_unique", + "billing_catalog_revisions_revision_unique": { + "name": "billing_catalog_revisions_revision_unique", "nullsNotDistinct": false, - "columns": ["domain_id"] + "columns": ["revision"] } }, "policies": {}, "checkConstraints": { - "sending_domains_domain_id_check": { - "name": "sending_domains_domain_id_check", - "value": "\"sending_domains\".\"domain_id\" ~ '^domain_'" - }, - "sending_domains_status_check": { - "name": "sending_domains_status_check", - "value": "\"sending_domains\".\"status\" IN ('pending', 'verified', 'revoked', 'failed')" + "billing_catalog_revisions_status_check": { + "name": "billing_catalog_revisions_status_check", + "value": "\"billing_catalog_revisions\".\"status\" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')" }, - "sending_domains_failed_check_count_check": { - "name": "sending_domains_failed_check_count_check", - "value": "\"sending_domains\".\"failed_check_count\" >= 0" + "billing_catalog_revisions_revision_check": { + "name": "billing_catalog_revisions_revision_check", + "value": "\"billing_catalog_revisions\".\"revision\" > 0" } }, "isRLSEnabled": false }, - "public.sequence_emails": { - "name": "sequence_emails", + "public.billing_checkout_attempts": { + "name": "billing_checkout_attempts", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, "notNull": true }, - "sequence_id": { - "name": "sequence_id", + "billable_entity_id": { + "name": "billable_entity_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "email_id": { - "name": "email_id", + "payer_id": { + "name": "payer_id", "type": "text", "primaryKey": false, "notNull": true }, - "subject": { - "name": "subject", + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true }, - "content": { - "name": "content", - "type": "jsonb", + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", "primaryKey": false, "notNull": true }, - "delay_in_millis": { - "name": "delay_in_millis", - "type": "bigint", + "offer_key": { + "name": "offer_key", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 86400000 + "notNull": true }, - "published": { - "name": "published", - "type": "boolean", + "requested_plan": { + "name": "requested_plan", + "type": "text", "primaryKey": false, - "notNull": true, - "default": false + "notNull": true }, - "template_id": { - "name": "template_id", + "requested_interval": { + "name": "requested_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quoted_amount_minor": { + "name": "quoted_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quoted_currency": { + "name": "quoted_currency", "type": "text", "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, "notNull": false }, - "action_type": { - "name": "action_type", + "provider_checkout_session_id": { + "name": "provider_checkout_session_id", "type": "text", "primaryKey": false, "notNull": false }, - "action_data": { - "name": "action_data", - "type": "jsonb", + "checkout_url_encrypted": { + "name": "checkout_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, @@ -7585,213 +7601,385 @@ "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" + }, + "pending_team_name": { + "name": "pending_team_name", + "type": "text", + "primaryKey": false, + "notNull": false } }, "indexes": { - "sequence_emails_sequence_id_email_id_idx": { - "name": "sequence_emails_sequence_id_email_id_idx", + "billing_checkout_attempts_provider_session_uidx": { + "name": "billing_checkout_attempts_provider_session_uidx", "columns": [ { - "expression": "sequence_id", + "expression": "provider", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "email_id", + "expression": "provider_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"provider_checkout_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_idempotency_uidx": { + "name": "billing_checkout_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_entity_nonterminal_uidx": { + "name": "billing_checkout_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, + "where": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open')", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "sequence_emails_sequence_id_sequences_id_fk": { - "name": "sequence_emails_sequence_id_sequences_id_fk", - "tableFrom": "sequence_emails", - "tableTo": "sequences", - "columnsFrom": ["sequence_id"], + "billing_checkout_attempts_billable_entity_id_organizations_id_fk": { + "name": "billing_checkout_attempts_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "organizations", + "columnsFrom": ["billable_entity_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_payer_id_user_id_fk": { + "name": "billing_checkout_attempts_payer_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": ["payer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": ["billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_provider_customers", + "columnsFrom": ["billing_customer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "billing_checkout_attempts_attempt_id_unique": { + "name": "billing_checkout_attempts_attempt_id_unique", + "nullsNotDistinct": false, + "columns": ["attempt_id"] + } + }, "policies": {}, "checkConstraints": { - "sequence_emails_email_id_check": { - "name": "sequence_emails_email_id_check", - "value": "\"sequence_emails\".\"email_id\" ~ '^email_'" + "billing_checkout_attempts_status_check": { + "name": "billing_checkout_attempts_status_check", + "value": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')" + }, + "billing_checkout_attempts_amount_check": { + "name": "billing_checkout_attempts_amount_check", + "value": "\"billing_checkout_attempts\".\"quoted_amount_minor\" > 0" + }, + "billing_checkout_attempts_plan_check": { + "name": "billing_checkout_attempts_plan_check", + "value": "\"billing_checkout_attempts\".\"requested_plan\" IN ('pro', 'business')" + }, + "billing_checkout_attempts_interval_check": { + "name": "billing_checkout_attempts_interval_check", + "value": "\"billing_checkout_attempts\".\"requested_interval\" IN ('month', 'year')" } }, "isRLSEnabled": false }, - "public.sequences": { - "name": "sequences", + "public.billing_plan_change_attempts": { + "name": "billing_plan_change_attempts", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "change_id": { + "name": "change_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", + "subscription_id": { + "name": "subscription_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "sequence_id": { - "name": "sequence_id", + "actor_id": { + "name": "actor_id", "type": "text", "primaryKey": false, "notNull": true }, - "type": { - "name": "type", + "payer_id": { + "name": "payer_id", "type": "text", "primaryKey": false, "notNull": true }, - "title": { - "name": "title", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, - "notNull": true, - "default": "''" + "notNull": true }, - "status": { - "name": "status", + "idempotency_key": { + "name": "idempotency_key", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'draft'" + "notNull": true }, - "delivery_source_intent": { - "name": "delivery_source_intent", - "type": "jsonb", + "current_catalog_revision": { + "name": "current_catalog_revision", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true }, - "delivery_source_type": { - "name": "delivery_source_type", + "current_billing_price_entry_id": { + "name": "current_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_plan": { + "name": "current_plan", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "outbox_id": { - "name": "outbox_id", - "type": "uuid", + "current_interval": { + "name": "current_interval", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "esp_grant_id": { - "name": "esp_grant_id", + "target_catalog_revision": { + "name": "target_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_billing_price_entry_id": { + "name": "target_billing_price_entry_id", "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "trigger_type": { - "name": "trigger_type", + "target_plan": { + "name": "target_plan", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "trigger_data": { - "name": "trigger_data", + "target_interval": { + "name": "target_interval", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "filter": { - "name": "filter", - "type": "jsonb", + "target_offer_key": { + "name": "target_offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proration_mode": { + "name": "proration_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_id": { + "name": "provider_payment_id", + "type": "text", "primaryKey": false, "notNull": false }, - "exclude_filter": { - "name": "exclude_filter", - "type": "jsonb", + "payment_url_encrypted": { + "name": "payment_url_encrypted", + "type": "text", "primaryKey": false, "notNull": false }, - "emails_order": { - "name": "emails_order", - "type": "text[]", + "status": { + "name": "status", + "type": "text", "primaryKey": false, "notNull": true, - "default": "'{}'" + "default": "'creating'" }, - "entrants": { - "name": "entrants", - "type": "text[]", + "last_error": { + "name": "last_error", + "type": "text", "primaryKey": false, - "notNull": true, - "default": "'{}'" + "notNull": false }, - "report": { - "name": "report", - "type": "jsonb", + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" } }, - "indexes": {}, + "indexes": { + "billing_plan_change_attempts_idempotency_uidx": { + "name": "billing_plan_change_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_plan_change_attempts_entity_nonterminal_uidx": { + "name": "billing_plan_change_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": { - "sequences_team_id_teams_id_fk": { - "name": "sequences_team_id_teams_id_fk", - "tableFrom": "sequences", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "billing_plan_change_attempts_billable_entity_id_organizations_id_fk": { + "name": "billing_plan_change_attempts_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "organizations", + "columnsFrom": ["billable_entity_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" }, - "sequences_outbox_id_esp_configs_id_fk": { - "name": "sequences_outbox_id_esp_configs_id_fk", - "tableFrom": "sequences", - "tableTo": "esp_configs", - "columnsFrom": ["outbox_id"], + "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_subscriptions", + "columnsFrom": ["subscription_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, - "sequences_esp_grant_id_esp_config_team_grants_id_fk": { - "name": "sequences_esp_grant_id_esp_config_team_grants_id_fk", - "tableFrom": "sequences", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["esp_grant_id"], + "billing_plan_change_attempts_payer_id_user_id_fk": { + "name": "billing_plan_change_attempts_payer_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": ["payer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": ["current_billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": ["target_billing_price_entry_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" @@ -7799,246 +7987,355 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "sequences_sequence_id_unique": { - "name": "sequences_sequence_id_unique", + "billing_plan_change_attempts_change_id_unique": { + "name": "billing_plan_change_attempts_change_id_unique", "nullsNotDistinct": false, - "columns": ["sequence_id"] + "columns": ["change_id"] } }, "policies": {}, "checkConstraints": { - "sequences_sequence_id_check": { - "name": "sequences_sequence_id_check", - "value": "\"sequences\".\"sequence_id\" ~ '^seq_'" + "billing_plan_change_attempts_status_check": { + "name": "billing_plan_change_attempts_status_check", + "value": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')" }, - "sequences_delivery_pin_check": { - "name": "sequences_delivery_pin_check", - "value": "(\n \"sequences\".\"delivery_source_type\" IS NULL\n AND \"sequences\".\"outbox_id\" IS NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'team'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'organization'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NOT NULL\n )" + "billing_plan_change_attempts_effective_at_check": { + "name": "billing_plan_change_attempts_effective_at_check", + "value": "\"billing_plan_change_attempts\".\"effective_at\" IN ('immediately', 'next_billing_date')" + }, + "billing_plan_change_attempts_proration_mode_check": { + "name": "billing_plan_change_attempts_proration_mode_check", + "value": "\"billing_plan_change_attempts\".\"proration_mode\" IN ('prorated_immediately', 'do_not_bill')" + }, + "billing_plan_change_attempts_current_plan_check": { + "name": "billing_plan_change_attempts_current_plan_check", + "value": "\"billing_plan_change_attempts\".\"current_plan\" IN ('pro', 'business')" + }, + "billing_plan_change_attempts_target_plan_check": { + "name": "billing_plan_change_attempts_target_plan_check", + "value": "\"billing_plan_change_attempts\".\"target_plan\" IN ('pro', 'business')" } }, "isRLSEnabled": false }, - "public.session": { - "name": "session", + "public.billing_plan_states": { + "name": "billing_plan_states", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "gen_random_uuid()" }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "token": { - "name": "token", - "type": "text", + "active_subscription_id": { + "name": "active_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "projection_version": { + "name": "projection_version", + "type": "integer", "primaryKey": false, - "notNull": true + "notNull": true, + "default": 0 }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, - "ip_address": { - "name": "ip_address", + "plan": { + "name": "plan", "type": "text", "primaryKey": false, + "notNull": true + }, + "teams_limit_override": { + "name": "teams_limit_override", + "type": "integer", + "primaryKey": false, "notNull": false }, - "user_agent": { - "name": "user_agent", - "type": "text", + "contacts_limit_override": { + "name": "contacts_limit_override", + "type": "integer", "primaryKey": false, "notNull": false }, - "user_id": { - "name": "user_id", - "type": "text", + "first_paid_activated_at": { + "name": "first_paid_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ramp_stage": { + "name": "ramp_stage", + "type": "integer", "primaryKey": false, "notNull": true + }, + "ramp_clean_stage_days": { + "name": "ramp_clean_stage_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ramp_evaluated_at": { + "name": "ramp_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, - "indexes": { - "auth_session_user_id_idx": { - "name": "auth_session_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, + "indexes": {}, "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], + "billing_plan_states_billable_entity_id_organizations_id_fk": { + "name": "billing_plan_states_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "organizations", + "columnsFrom": ["billable_entity_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "billing_subscriptions", + "columnsFrom": ["active_subscription_id"], + "columnsTo": ["id"], + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", + "billing_plan_states_billable_entity_id_unique": { + "name": "billing_plan_states_billable_entity_id_unique", "nullsNotDistinct": false, - "columns": ["token"] + "columns": ["billable_entity_id"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.settings": { - "name": "settings", + "public.billing_price_entries": { + "name": "billing_price_entries", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", + "plan": { + "name": "plan", + "type": "text", "primaryKey": false, "notNull": true }, - "mailing_address": { - "name": "mailing_address", + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_trial_days": { + "name": "provider_trial_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", "type": "text", "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" } }, - "indexes": {}, - "foreignKeys": { - "settings_team_id_teams_id_fk": { - "name": "settings_team_id_teams_id_fk", - "tableFrom": "settings", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "indexes": { + "billing_price_entries_provider_product_uidx": { + "name": "billing_price_entries_provider_product_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_entries_offer_key_idx": { + "name": "billing_price_entries_offer_key_idx", + "columns": [ + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "settings_team_id_unique": { - "name": "settings_team_id_unique", - "nullsNotDistinct": false, - "columns": ["team_id"] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_price_entries_amount_check": { + "name": "billing_price_entries_amount_check", + "value": "\"billing_price_entries\".\"amount_minor\" > 0" + }, + "billing_price_entries_trial_days_check": { + "name": "billing_price_entries_trial_days_check", + "value": "\"billing_price_entries\".\"provider_trial_days\" >= 0" + }, + "billing_price_entries_currency_check": { + "name": "billing_price_entries_currency_check", + "value": "\"billing_price_entries\".\"currency\" ~ '^[A-Z]{3}$'" + }, + "billing_price_entries_plan_check": { + "name": "billing_price_entries_plan_check", + "value": "\"billing_price_entries\".\"plan\" IN ('pro', 'business')" + }, + "billing_price_entries_interval_check": { + "name": "billing_price_entries_interval_check", + "value": "\"billing_price_entries\".\"billing_interval\" IN ('month', 'year')" } }, - "policies": {}, - "checkConstraints": {}, "isRLSEnabled": false }, - "public.team_api_keys": { - "name": "team_api_keys", + "public.billing_provider_customers": { + "name": "billing_provider_customers", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "gen_random_uuid()" }, - "team_api_key_id": { - "name": "team_api_key_id", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key_hash": { - "name": "key_hash", + "payer_id": { + "name": "payer_id", "type": "text", "primaryKey": false, "notNull": true }, - "key_prefix": { - "name": "key_prefix", + "payer_email": { + "name": "payer_email", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "''" }, - "name": { - "name": "name", + "provider_customer_id": { + "name": "provider_customer_id", "type": "text", "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, "notNull": false }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", + "idempotency_key": { + "name": "idempotency_key", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "created_by_type": { - "name": "created_by_type", + "status": { + "name": "status", "type": "text", "primaryKey": false, "notNull": true, - "default": "'user'" + "default": "'creating'" }, - "created_by_id": { - "name": "created_by_id", + "last_error": { + "name": "last_error", "type": "text", "primaryKey": false, "notNull": false @@ -8049,185 +8346,189 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": { - "team_api_keys_team_id_idx": { - "name": "team_api_keys_team_id_idx", + "billing_provider_customers_provider_payer_uidx": { + "name": "billing_provider_customers_provider_payer_uidx", "columns": [ { - "expression": "team_id", + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payer_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_provider_customer_uidx": { + "name": "billing_provider_customers_provider_customer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_provider_customers\".\"provider_customer_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_idempotency_uidx": { + "name": "billing_provider_customers_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "team_api_keys_team_id_teams_id_fk": { - "name": "team_api_keys_team_id_teams_id_fk", - "tableFrom": "team_api_keys", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "billing_provider_customers_payer_id_user_id_fk": { + "name": "billing_provider_customers_payer_id_user_id_fk", + "tableFrom": "billing_provider_customers", + "tableTo": "user", + "columnsFrom": ["payer_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "team_api_keys_team_api_key_id_unique": { - "name": "team_api_keys_team_api_key_id_unique", - "nullsNotDistinct": false, - "columns": ["team_api_key_id"] - }, - "team_api_keys_key_hash_unique": { - "name": "team_api_keys_key_hash_unique", - "nullsNotDistinct": false, - "columns": ["key_hash"] - } - }, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "team_api_keys_public_id_check": { - "name": "team_api_keys_public_id_check", - "value": "\"team_api_keys\".\"team_api_key_id\" ~ '^tak_'" - }, - "team_api_keys_created_by_type_check": { - "name": "team_api_keys_created_by_type_check", - "value": "\"team_api_keys\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + "billing_provider_customers_status_check": { + "name": "billing_provider_customers_status_check", + "value": "\"billing_provider_customers\".\"status\" IN ('creating', 'active', 'conflicted')" } }, "isRLSEnabled": false }, - "public.team_delivery_settings": { - "name": "team_delivery_settings", + "public.billing_reconciliation_jobs": { + "name": "billing_reconciliation_jobs", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "gen_random_uuid()" }, - "team_id": { - "name": "team_id", - "type": "uuid", + "provider": { + "name": "provider", + "type": "text", "primaryKey": false, "notNull": true }, - "team_esp_enabled": { - "name": "team_esp_enabled", - "type": "boolean", + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", "primaryKey": false, - "notNull": true, - "default": true + "notNull": false }, - "team_can_change_default": { - "name": "team_can_change_default", - "type": "boolean", + "plan_change_attempt_id": { + "name": "plan_change_attempt_id", + "type": "uuid", "primaryKey": false, - "notNull": true, - "default": true + "notNull": false }, - "default_source": { - "name": "default_source", - "type": "text", + "subscription_id": { + "name": "subscription_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "default_team_esp_config_id": { - "name": "default_team_esp_config_id", + "provider_customer_id": { + "name": "provider_customer_id", "type": "uuid", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "operation": { + "name": "operation", + "type": "text", "primaryKey": false, "notNull": true, - "default": "now()" + "default": "'reconcile'" }, - "updated_at": { - "name": "updated_at", + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "team_delivery_settings_team_id_teams_id_fk": { - "name": "team_delivery_settings_team_id_teams_id_fk", - "tableFrom": "team_delivery_settings", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" }, - "team_delivery_settings_default_team_esp_fk": { - "name": "team_delivery_settings_default_team_esp_fk", - "tableFrom": "team_delivery_settings", - "tableTo": "esp_configs", - "columnsFrom": ["default_team_esp_config_id", "team_id"], - "columnsTo": ["id", "team_id"], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "team_delivery_settings_team_id_unique": { - "name": "team_delivery_settings_team_id_unique", - "nullsNotDistinct": false, - "columns": ["team_id"] - } - }, - "policies": {}, - "checkConstraints": { - "team_delivery_settings_default_source_check": { - "name": "team_delivery_settings_default_source_check", - "value": "\"team_delivery_settings\".\"default_source\" IS NULL OR \"team_delivery_settings\".\"default_source\" IN ('organization', 'team')" - } - }, - "isRLSEnabled": false - }, - "public.team_members": { - "name": "team_members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false }, - "team_id": { - "name": "team_id", - "type": "uuid", + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false }, - "user_id": { - "name": "user_id", + "worker_id": { + "name": "worker_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "role": { - "name": "role", + "last_error": { + "name": "last_error", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'member'" + "notNull": false }, "created_at": { "name": "created_at", @@ -8236,52 +8537,113 @@ "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "team_members_team_id_user_id_idx": { - "name": "team_members_team_id_user_id_idx", + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_reconciliation_jobs_live_checkout_uidx": { + "name": "billing_reconciliation_jobs_live_checkout_uidx", + "columns": [ + { + "expression": "checkout_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_plan_change_uidx": { + "name": "billing_reconciliation_jobs_live_plan_change_uidx", + "columns": [ + { + "expression": "plan_change_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_subscription_uidx": { + "name": "billing_reconciliation_jobs_live_subscription_uidx", "columns": [ { - "expression": "team_id", + "expression": "subscription_id", "isExpression": false, "asc": true, "nulls": "last" - }, + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_customer_uidx": { + "name": "billing_reconciliation_jobs_live_customer_uidx", + "columns": [ { - "expression": "user_id", + "expression": "provider_customer_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "team_members_team_id_teams_id_fk": { - "name": "team_members_team_id_teams_id_fk", - "tableFrom": "team_members", - "tableTo": "teams", - "columnsFrom": ["team_id"], + "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_checkout_attempts", + "columnsFrom": ["checkout_attempt_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" }, - "team_members_user_id_user_id_fk": { - "name": "team_members_user_id_user_id_fk", - "tableFrom": "team_members", - "tableTo": "user", - "columnsFrom": ["user_id"], + "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk": { + "name": "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_plan_change_attempts", + "columnsFrom": ["plan_change_attempt_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk": { + "name": "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_provider_customers", + "columnsFrom": ["provider_customer_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" @@ -8291,199 +8653,172 @@ "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "team_members_role_check": { - "name": "team_members_role_check", - "value": "\"team_members\".\"role\" IN ('admin', 'member')" + "billing_reconciliation_jobs_exactly_one_subject": { + "name": "billing_reconciliation_jobs_exactly_one_subject", + "value": "((CASE WHEN \"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL THEN 1 ELSE 0 END)) = 1" + }, + "billing_reconciliation_jobs_status_check": { + "name": "billing_reconciliation_jobs_status_check", + "value": "\"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed', 'completed', 'quarantined')" + }, + "billing_reconciliation_jobs_operation_check": { + "name": "billing_reconciliation_jobs_operation_check", + "value": "\"billing_reconciliation_jobs\".\"operation\" = 'reconcile' OR (\"billing_reconciliation_jobs\".\"operation\" = 'cancellation' AND \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL)" } }, "isRLSEnabled": false }, - "public.team_sending_controls": { - "name": "team_sending_controls", + "public.billing_subscriptions": { + "name": "billing_subscriptions", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, "notNull": true }, - "team_id": { - "name": "team_id", + "billing_customer_id": { + "name": "billing_customer_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "status": { - "name": "status", + "payer_id": { + "name": "payer_id", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'normal'" + "notNull": true }, - "reason_code": { - "name": "reason_code", - "type": "text", + "origin_checkout_attempt_id": { + "name": "origin_checkout_attempt_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "source": { - "name": "source", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, - "notNull": true, - "default": "'automatic'" + "notNull": true }, - "entered_at": { - "name": "entered_at", - "type": "timestamp with time zone", + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "evaluated_at": { - "name": "evaluated_at", - "type": "timestamp with time zone", + "provider_product_id": { + "name": "provider_product_id", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "minimum_hold_until": { - "name": "minimum_hold_until", - "type": "timestamp with time zone", + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, - "operator_user_id": { - "name": "operator_user_id", + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "operator_reason": { - "name": "operator_reason", + "plan": { + "name": "plan", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "overridden_at": { - "name": "overridden_at", - "type": "timestamp with time zone", + "billing_interval": { + "name": "billing_interval", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "clean_evaluation_days": { - "name": "clean_evaluation_days", - "type": "integer", + "status": { + "name": "status", + "type": "text", "primaryKey": false, "notNull": true, - "default": 0 + "default": "'pending'" }, - "created_at": { - "name": "created_at", + "current_period_starts_at": { + "name": "current_period_starts_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false }, - "updated_at": { - "name": "updated_at", + "current_period_ends_at": { + "name": "current_period_ends_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "team_sending_controls_team_id_teams_id_fk": { - "name": "team_sending_controls_team_id_teams_id_fk", - "tableFrom": "team_sending_controls", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "team_sending_controls_operator_user_id_user_id_fk": { - "name": "team_sending_controls_operator_user_id_user_id_fk", - "tableFrom": "team_sending_controls", - "tableTo": "user", - "columnsFrom": ["operator_user_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "team_sending_controls_team_id_unique": { - "name": "team_sending_controls_team_id_unique", - "nullsNotDistinct": false, - "columns": ["team_id"] - } - }, - "policies": {}, - "checkConstraints": { - "team_sending_controls_status_check": { - "name": "team_sending_controls_status_check", - "value": "\"team_sending_controls\".\"status\" IN ('normal', 'warned', 'marketing_paused', 'all_paused')" + "notNull": false }, - "team_sending_controls_source_check": { - "name": "team_sending_controls_source_check", - "value": "\"team_sending_controls\".\"source\" IN ('automatic', 'operator')" + "paid_through_at": { + "name": "paid_through_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false }, - "team_sending_controls_clean_days_check": { - "name": "team_sending_controls_clean_days_check", - "value": "\"team_sending_controls\".\"clean_evaluation_days\" >= 0" - } - }, - "isRLSEnabled": false - }, - "public.teams": { - "name": "teams", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false }, - "team_id": { - "name": "team_id", - "type": "text", + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", "primaryKey": false, - "notNull": true + "notNull": true, + "default": false }, - "organization_id": { - "name": "organization_id", - "type": "uuid", + "is_entitlement_source": { + "name": "is_entitlement_source", + "type": "boolean", "primaryKey": false, - "notNull": true + "notNull": true, + "default": false }, - "external_id": { - "name": "external_id", - "type": "text", + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, - "provisioning_request_hash": { - "name": "provisioning_request_hash", + "provider_version": { + "name": "provider_version", "type": "text", "primaryKey": false, "notNull": false }, - "name": { - "name": "name", - "type": "text", + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false }, - "status": { - "name": "status", - "type": "text", + "last_reconciled_at": { + "name": "last_reconciled_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": "'active'" + "notNull": false }, "created_at": { "name": "created_at", @@ -8498,161 +8833,187 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "past_due_at": { + "name": "past_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_ends_at": { + "name": "grace_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false } }, "indexes": { - "teams_organization_id_external_id_idx": { - "name": "teams_organization_id_external_id_idx", + "billing_subscriptions_provider_subscription_uidx": { + "name": "billing_subscriptions_provider_subscription_uidx", "columns": [ { - "expression": "organization_id", + "expression": "provider", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "external_id", + "expression": "provider_subscription_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, - "where": "\"teams\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_entity_source_uidx": { + "name": "billing_subscriptions_entity_source_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_subscriptions\".\"is_entitlement_source\" = true", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "teams_organization_id_organizations_id_fk": { - "name": "teams_organization_id_organizations_id_fk", - "tableFrom": "teams", + "billing_subscriptions_billable_entity_id_organizations_id_fk": { + "name": "billing_subscriptions_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_subscriptions", "tableTo": "organizations", - "columnsFrom": ["organization_id"], + "columnsFrom": ["billable_entity_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "teams_team_id_unique": { - "name": "teams_team_id_unique", - "nullsNotDistinct": false, - "columns": ["team_id"] }, - "teams_id_organization_id_unique": { - "name": "teams_id_organization_id_unique", - "nullsNotDistinct": false, - "columns": ["id", "organization_id"] + "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_provider_customers", + "columnsFrom": ["billing_customer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_payer_id_user_id_fk": { + "name": "billing_subscriptions_payer_id_user_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "user", + "columnsFrom": ["payer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_checkout_attempts", + "columnsFrom": ["origin_checkout_attempt_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_price_entries", + "columnsFrom": ["billing_price_entry_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "teams_team_id_check": { - "name": "teams_team_id_check", - "value": "\"teams\".\"team_id\" ~ '^team_'" + "billing_subscriptions_status_check": { + "name": "billing_subscriptions_status_check", + "value": "\"billing_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" }, - "teams_status_check": { - "name": "teams_status_check", - "value": "\"teams\".\"status\" IN ('active', 'sending_suspended', 'archived')" + "billing_subscriptions_plan_check": { + "name": "billing_subscriptions_plan_check", + "value": "\"billing_subscriptions\".\"plan\" IN ('pro', 'business')" + }, + "billing_subscriptions_interval_check": { + "name": "billing_subscriptions_interval_check", + "value": "\"billing_subscriptions\".\"billing_interval\" IN ('month', 'year')" } }, "isRLSEnabled": false }, - "public.transactional_emails": { - "name": "transactional_emails", + "public.billing_webhook_events": { + "name": "billing_webhook_events", "schema": "", "columns": { "id": { "name": "id", "type": "uuid", "primaryKey": true, - "notNull": true + "notNull": true, + "default": "gen_random_uuid()" }, - "team_id": { - "name": "team_id", - "type": "uuid", + "provider": { + "name": "provider", + "type": "text", "primaryKey": false, "notNull": true }, - "txe_id": { - "name": "txe_id", + "provider_event_id": { + "name": "provider_event_id", "type": "text", "primaryKey": false, "notNull": true }, - "delivery_source_type": { - "name": "delivery_source_type", + "event_type": { + "name": "event_type", "type": "text", "primaryKey": false, "notNull": true }, - "outbox_id": { - "name": "outbox_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "esp_grant_id": { - "name": "esp_grant_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "to_email": { - "name": "to_email", - "type": "text", + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, - "from_email": { - "name": "from_email", + "subscription_id": { + "name": "subscription_id", "type": "text", "primaryKey": false, "notNull": false }, - "reply_to": { - "name": "reply_to", + "checkout_attempt_id": { + "name": "checkout_attempt_id", "type": "text", "primaryKey": false, "notNull": false }, - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "template_id": { - "name": "template_id", + "payload_encrypted": { + "name": "payload_encrypted", "type": "text", "primaryKey": false, "notNull": false }, - "html": { - "name": "html", + "payload_key_version": { + "name": "payload_key_version", "type": "text", "primaryKey": false, "notNull": false }, - "variables": { - "name": "variables", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "contact_id": { - "name": "contact_id", - "type": "uuid", + "verified_key_version": { + "name": "verified_key_version", + "type": "text", "primaryKey": false, "notNull": false }, @@ -8661,130 +9022,93 @@ "type": "text", "primaryKey": false, "notNull": true, - "default": "'queued'" - }, - "processing_started_at": { - "name": "processing_started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false + "default": "'pending'" }, - "error": { - "name": "error", - "type": "text", + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", "primaryKey": false, - "notNull": false + "notNull": true, + "default": 0 }, - "idempotency_key": { - "name": "idempotency_key", + "last_error": { + "name": "last_error", "type": "text", "primaryKey": false, "notNull": false }, - "track_opens": { - "name": "track_opens", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "track_clicks": { - "name": "track_clicks", - "type": "boolean", + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "default": false + "default": "now()" }, - "open_count": { - "name": "open_count", - "type": "integer", + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false }, - "click_count": { - "name": "click_count", - "type": "integer", + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": false }, - "sent_at": { - "name": "sent_at", - "type": "timestamp with time zone", + "worker_id": { + "name": "worker_id", + "type": "text", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", + "received_at": { + "name": "received_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, + "notNull": true, "default": "now()" }, - "updated_at": { - "name": "updated_at", + "processed_at": { + "name": "processed_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": false, - "default": "now()" + "notNull": false } }, "indexes": { - "transactional_emails_team_id_idempotency_key_idx": { - "name": "transactional_emails_team_id_idempotency_key_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "idempotency_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"transactional_emails\".\"idempotency_key\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "transactional_emails_team_id_created_at_idx": { - "name": "transactional_emails_team_id_created_at_idx", + "billing_webhook_events_provider_event_uidx": { + "name": "billing_webhook_events_provider_event_uidx", "columns": [ { - "expression": "team_id", + "expression": "provider", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", + "expression": "provider_event_id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} }, - "transactional_emails_team_id_status_idx": { - "name": "transactional_emails_team_id_status_idx", + "billing_webhook_events_queue_idx": { + "name": "billing_webhook_events_queue_idx", "columns": [ { - "expression": "team_id", + "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "status", + "expression": "available_at", "isExpression": false, "asc": true, "nulls": "last" @@ -8796,206 +9120,182 @@ "with": {} } }, - "foreignKeys": { - "transactional_emails_team_id_teams_id_fk": { - "name": "transactional_emails_team_id_teams_id_fk", - "tableFrom": "transactional_emails", - "tableTo": "teams", - "columnsFrom": ["team_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "transactional_emails_outbox_id_esp_configs_id_fk": { - "name": "transactional_emails_outbox_id_esp_configs_id_fk", - "tableFrom": "transactional_emails", - "tableTo": "esp_configs", - "columnsFrom": ["outbox_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk": { - "name": "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk", - "tableFrom": "transactional_emails", - "tableTo": "esp_config_team_grants", - "columnsFrom": ["esp_grant_id"], - "columnsTo": ["id"], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "transactional_emails_contact_id_contacts_id_fk": { - "name": "transactional_emails_contact_id_contacts_id_fk", - "tableFrom": "transactional_emails", - "tableTo": "contacts", - "columnsFrom": ["contact_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, + "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "transactional_emails_txe_id_unique": { - "name": "transactional_emails_txe_id_unique", - "nullsNotDistinct": false, - "columns": ["txe_id"] - } - }, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "transactional_emails_txe_id_check": { - "name": "transactional_emails_txe_id_check", - "value": "\"transactional_emails\".\"txe_id\" ~ '^txe_'" - }, - "transactional_emails_delivery_pin_check": { - "name": "transactional_emails_delivery_pin_check", - "value": "(\n \"transactional_emails\".\"delivery_source_type\" = 'team'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NULL\n ) OR (\n \"transactional_emails\".\"delivery_source_type\" = 'organization'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NOT NULL\n )" + "billing_webhook_events_status_check": { + "name": "billing_webhook_events_status_check", + "value": "\"billing_webhook_events\".\"status\" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')" } }, "isRLSEnabled": false }, - "public.user": { - "name": "user", + "public.billing_trial_claims": { + "name": "billing_trial_claims", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "uuid", "primaryKey": true, "notNull": true }, - "name": { - "name": "name", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "email": { - "name": "email", + "verified_email_fingerprint": { + "name": "verified_email_fingerprint", "type": "text", "primaryKey": false, "notNull": true }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", + "fingerprint_key_version": { + "name": "fingerprint_key_version", "type": "text", "primaryKey": false, - "notNull": false - }, - "default_organization_id": { - "name": "default_organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": false + "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "trial_key": { + "name": "trial_key", + "type": "text", "primaryKey": false, "notNull": true }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", + "organization_id": { + "name": "organization_id", + "type": "uuid", "primaryKey": false, "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "user_default_organization_id_organizations_id_fk": { - "name": "user_default_organization_id_organizations_id_fk", - "tableFrom": "user", - "tableTo": "organizations", - "columnsFrom": ["default_organization_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true }, - "identifier": { - "name": "identifier", - "type": "text", + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, - "value": { - "name": "value", + "status": { + "name": "status", "type": "text", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "'reserved'" }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" } }, "indexes": { - "auth_verification_identifier_idx": { - "name": "auth_verification_identifier_idx", + "billing_trial_claims_user_trial_uidx": { + "name": "billing_trial_claims_user_trial_uidx", "columns": [ { - "expression": "identifier", + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trial_key", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, + "where": "\"billing_trial_claims\".\"status\" <> 'released'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_trial_claims_email_trial_uidx": { + "name": "billing_trial_claims_email_trial_uidx", + "columns": [ + { + "expression": "verified_email_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trial_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_trial_claims\".\"status\" <> 'released'", "concurrently": false, "method": "btree", "with": {} } }, - "foreignKeys": {}, + "foreignKeys": { + "billing_trial_claims_user_id_user_id_fk": { + "name": "billing_trial_claims_user_id_user_id_fk", + "tableFrom": "billing_trial_claims", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_trial_claims_organization_id_organizations_id_fk": { + "name": "billing_trial_claims_organization_id_organizations_id_fk", + "tableFrom": "billing_trial_claims", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_trial_claims_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_trial_claims", + "tableTo": "billing_checkout_attempts", + "columnsFrom": ["checkout_attempt_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "billing_trial_claims_status_check": { + "name": "billing_trial_claims_status_check", + "value": "\"billing_trial_claims\".\"status\" IN ('reserved', 'redeemed', 'released')" + } + }, "isRLSEnabled": false } }, diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index b8f2107..c31f74a 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "7", - "when": 1788000470925, - "tag": "0005_many_shape", + "when": 1788113568973, + "tag": "0005_billing", "breakpoints": true } ] diff --git a/apps/api/package.json b/apps/api/package.json index 9d3912b..4e3423d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,14 +6,17 @@ "license": "AGPL-3.0-or-later", "main": "dist/index.js", "scripts": { + "prebuild": "codelit-billing generate --check --config billing.config.ts", "build": "tsc", "predev": "pnpm --filter @sendlit/api-contract build", "dev": "nodemon --exec 'node --env-file=.env --import tsx' src/index.ts", "access-token": "tsx --env-file=.env scripts/token.ts", "billing": "tsx --env-file=.env scripts/billing.ts", + "billing:generate": "codelit-billing generate --config billing.config.ts", + "billing:generate:check": "codelit-billing generate --check --config billing.config.ts", "start": "node dist/index.js", "db:migrate": "node --import dotenv/config dist/db/migrate.js", - "db:generate": "drizzle-kit generate", + "db:generate": "codelit-billing generate --config billing.config.ts && drizzle-kit generate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "test": "vitest run", @@ -23,6 +26,7 @@ "dependencies": { "@better-auth/cimd": "1.7.0-rc.4", "@better-auth/oauth-provider": "1.7.0-rc.4", + "@codelitdev/billing": "0.1.0-alpha.3", "@codelitdev/oauth-server-kit": "0.1.0-alpha.1", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", diff --git a/apps/api/scripts/billing.ts b/apps/api/scripts/billing.ts index abfcf02..84e4c8b 100644 --- a/apps/api/scripts/billing.ts +++ b/apps/api/scripts/billing.ts @@ -5,8 +5,8 @@ import { db, pool } from "../src/db/client"; import { billingCatalogRevisions, billingWebhookEvents, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, organizations, teams, } from "../src/db/schema"; @@ -16,13 +16,10 @@ import { recordRequestedCatalogRevision, verifyCatalogAgainstProvider, } from "../src/billing/catalog-store"; -import { getBillingProvider } from "../src/billing/provider-registry"; -import { applyCanonicalBillingEvent } from "../src/billing/webhooks/processor"; import { applyTeamSendingControl, releaseTeamSendingControl, } from "../src/billing/reputation"; -import { decryptBillingValue } from "../src/billing/crypto"; import { recordBillingMetric } from "../src/billing/metrics"; function usage(): never { @@ -73,8 +70,7 @@ async function catalogVerify() { console.log("oss mode: nothing to verify"); return; } - const provider = getBillingProvider(config.checkoutProvider ?? undefined); - await verifyCatalogAgainstProvider(config, provider); + await verifyCatalogAgainstProvider(config); console.log("catalog verified"); } @@ -106,11 +102,11 @@ async function reconcileOrg(publicId: string | undefined) { } const [subscription] = await db .select() - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - eq(organizationSubscriptions.organizationId, organization.id), - eq(organizationSubscriptions.isEntitlementSource, true), + eq(billingSubscriptions.billableEntityId, organization.id), + eq(billingSubscriptions.isEntitlementSource, true), ), ) .limit(1); @@ -118,45 +114,26 @@ async function reconcileOrg(publicId: string | undefined) { console.log("no entitlement-bearing subscription"); return; } - const provider = getBillingProvider(subscription.provider); - const snapshot = await provider.retrieveSubscription( - subscription.providerSubscriptionId, + const { getBillingOperations } = await import("../src/billing/engine.js"); + await getBillingOperations().reconcileSubscription( + { actorId: "operator", reason: "cli reconcile-org" }, + subscription.id, ); - await applyCanonicalBillingEvent({ - provider: subscription.provider, - providerEventId: `operator-reconcile:${subscription.id}:${new Date().toISOString()}`, - eventType: "subscription.reconciled", - occurredAt: snapshot.occurredAt, - subscriptionId: snapshot.providerSubscriptionId, - snapshot, - rawPayload: null, - }); console.log("reconciled", publicId); } async function webhookRetry(eventId: string | undefined) { if (!eventId) usage(); - const [event] = await db - .select() - .from(billingWebhookEvents) - .where(eq(billingWebhookEvents.providerEventId, eventId)) - .limit(1); - if (!event) { - console.error("event not found"); - process.exit(1); - } - await db - .update(billingWebhookEvents) - .set({ - status: "pending", - availableAt: new Date(), - lockedAt: null, - leaseExpiresAt: null, - }) - .where(eq(billingWebhookEvents.id, event.id)); - const { processBillingWebhookInboxEvent } = - await import("../src/billing/webhooks/processor.js"); - await processBillingWebhookInboxEvent(event.id); + const { getBillingEngine, getBillingOperations } = + await import("../src/billing/engine.js"); + await getBillingOperations().retryWebhook( + { actorId: "operator", reason: "cli webhook-retry" }, + eventId, + "preserve_attempts", + ); + await getBillingEngine().runWebhookInboxBatch({ + workerId: "cli-webhook-retry", + }); console.log("retried", eventId); } @@ -173,7 +150,13 @@ async function webhookInspect(eventId: string | undefined) { } let payload: unknown = null; if (event.payloadEncrypted) { - payload = JSON.parse(decryptBillingValue(event.payloadEncrypted)); + const { getBillingOperations } = + await import("../src/billing/engine.js"); + const plaintext = await getBillingOperations().decryptReplay( + { actorId: "operator", reason: "cli webhook-inspect" }, + event.payloadEncrypted, + ); + payload = JSON.parse(plaintext); } console.log( JSON.stringify( @@ -216,13 +199,13 @@ async function setOverride( return value; }; await db - .update(organizationPlanStates) + .update(billingPlanStates) .set({ teamsLimitOverride: parse(teamsRaw), contactsLimitOverride: parse(contactsRaw), updatedAt: new Date(), }) - .where(eq(organizationPlanStates.organizationId, organization.id)); + .where(eq(billingPlanStates.billableEntityId, organization.id)); recordBillingMetric("billing.operator.override", { organization_public_id: publicId, reason, @@ -292,11 +275,11 @@ async function cancelSubscription( } const [subscription] = await db .select() - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - eq(organizationSubscriptions.organizationId, organization.id), - eq(organizationSubscriptions.isEntitlementSource, true), + eq(billingSubscriptions.billableEntityId, organization.id), + eq(billingSubscriptions.isEntitlementSource, true), ), ) .limit(1); @@ -304,10 +287,10 @@ async function cancelSubscription( console.error("no live subscription"); process.exit(1); } - const provider = getBillingProvider(subscription.provider); - await provider.cancelSubscription( - subscription.providerSubscriptionId, - `operator-cancel:${subscription.id}`, + const { getBillingOperations } = await import("../src/billing/engine.js"); + await getBillingOperations().requestCancellation( + { actorId: "operator", reason }, + subscription.id, ); recordBillingMetric("billing.operator.cancel_subscription", { organization_public_id: publicId, diff --git a/apps/api/src/billing/alerts.ts b/apps/api/src/billing/alerts.ts index 1495034..22036ea 100644 --- a/apps/api/src/billing/alerts.ts +++ b/apps/api/src/billing/alerts.ts @@ -6,7 +6,7 @@ import { billingCheckoutAttempts, billingProviderCustomers, billingWebhookEvents, - organizationSubscriptions, + billingSubscriptions, } from "../db/schema"; import logger from "../services/log"; import { captureError, captureEvent } from "../observability/posthog"; @@ -226,10 +226,10 @@ export async function collectBillingSloAlerts( const unreconciledSince = new Date(now.getTime() - UNRECONCILED_MS); const [unreconciled] = await db .select({ value: sql`count(*)` }) - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - inArray(organizationSubscriptions.status, [ + inArray(billingSubscriptions.status, [ "pending", "trialing", "active", @@ -237,9 +237,9 @@ export async function collectBillingSloAlerts( "cancelled", ]), or( - isNull(organizationSubscriptions.lastReconciledAt), + isNull(billingSubscriptions.lastReconciledAt), lt( - organizationSubscriptions.lastReconciledAt, + billingSubscriptions.lastReconciledAt, unreconciledSince, ), ), diff --git a/apps/api/src/billing/authorization-port.ts b/apps/api/src/billing/authorization-port.ts new file mode 100644 index 0000000..8ab25e5 --- /dev/null +++ b/apps/api/src/billing/authorization-port.ts @@ -0,0 +1,84 @@ +import { and, eq, gt } from "drizzle-orm"; +import { createHash } from "node:crypto"; +import { BillingWorkflowError } from "@codelitdev/billing/core"; +import type { + BillingActionGrant, + BillingAuthorizationPort, +} from "@codelitdev/billing/workflows"; +import { db } from "../db/client"; +import { organizations, verification } from "../db/schema"; + +type StoredActionToken = { + userId: string; + sessionId: string; + action: string; + target: string; +}; + +function tokenHash(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +function expectedHttpActions(action: BillingActionGrant["action"]): string[] { + if (action === "checkout") return ["checkout", "organization_checkout"]; + if (action === "portal") return ["portal"]; + if (action === "plan_change") return ["plan_change"]; + if (action === "cancellation") return ["cancellation"]; + return [action]; +} + +export const sendlitBillingAuthorization: BillingAuthorizationPort = { + async consume(grant, expectedAction, expectedTarget, now) { + if (grant.grantId.startsWith("preconsumed:")) { + if ( + grant.action !== expectedAction || + grant.target.id !== expectedTarget.id + ) { + throw new BillingWorkflowError("grant_invalid"); + } + return; + } + const identifier = `billing-action:${tokenHash(grant.grantId)}`; + const consumed = await db.transaction(async (tx) => { + const [row] = await tx + .select() + .from(verification) + .where( + and( + eq(verification.identifier, identifier), + gt(verification.expiresAt, now), + ), + ) + .limit(1) + .for("update"); + if (!row) return false; + let value: StoredActionToken; + try { + value = JSON.parse(row.value) as StoredActionToken; + } catch { + return false; + } + if ( + value.userId !== grant.actorId || + !expectedHttpActions(expectedAction).includes(value.action) + ) { + return false; + } + if (value.action === "organization_checkout") { + if (value.target !== "new") return false; + } else { + const [organization] = await tx + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.organizationId, value.target)) + .limit(1); + if (!organization || organization.id !== expectedTarget.id) { + return false; + } + } + await tx.delete(verification).where(eq(verification.id, row.id)); + return true; + }); + if (!consumed) throw new BillingWorkflowError("grant_invalid"); + }, +}; diff --git a/apps/api/src/billing/catalog-store.test.ts b/apps/api/src/billing/catalog-store.test.ts index 6c62531..964c281 100644 --- a/apps/api/src/billing/catalog-store.test.ts +++ b/apps/api/src/billing/catalog-store.test.ts @@ -13,6 +13,10 @@ import { verifyCatalogAgainstProvider, } from "./catalog-store"; import { FakeBillingProvider } from "./providers/fake"; +import { + getBillingProvider, + resetBillingProviderInstances, +} from "./provider-registry"; const tdb = db as unknown as TestDb; @@ -35,22 +39,21 @@ function cloudFakeEnv() { beforeEach(async () => { cloudFakeEnv(); + resetBillingProviderInstances(); await truncateAll(tdb); }); describe("catalog verification against the fake adapter", () => { it("activates a four-offer revision when products match", async () => { - const provider = new FakeBillingProvider(); - provider.seedDefaultCatalog(); - await verifyCatalogAgainstProvider(readBillingConfig(), provider); + getBillingProvider("fake"); + await verifyCatalogAgainstProvider(readBillingConfig()); const active = await getActiveCatalog(readBillingConfig()); expect(active.revision.revision).toBe(1); expect(active.items).toHaveLength(4); }); it("rejects a provider amount mismatch without activating", async () => { - const provider = new FakeBillingProvider(); - provider.seedDefaultCatalog(); + const provider = getBillingProvider("fake") as FakeBillingProvider; provider.seedProduct({ provider: "fake", providerProductId: "pdt_pro_month", @@ -59,7 +62,7 @@ describe("catalog verification against the fake adapter", () => { interval: "month", }); await expect( - verifyCatalogAgainstProvider(readBillingConfig(), provider), + verifyCatalogAgainstProvider(readBillingConfig()), ).rejects.toThrow(/billing_catalog_unavailable/); }); }); diff --git a/apps/api/src/billing/catalog-store.ts b/apps/api/src/billing/catalog-store.ts index d9faec7..79d3321 100644 --- a/apps/api/src/billing/catalog-store.ts +++ b/apps/api/src/billing/catalog-store.ts @@ -5,13 +5,23 @@ import { billingCatalogRevisions, billingPriceEntries, } from "../db/schema"; -import type { BillingConfig, BillingOffer } from "./catalog"; -import { billingCatalogKeys } from "./catalog"; +import { + catalogMatchesProviderSnapshot, + checkoutIsAvailable as packageCheckoutIsAvailable, +} from "@codelitdev/billing/catalog"; +import type { BillingOffer } from "./catalog"; +import { + billingCatalogKeys, + readBillingConfig, + toPackageOffer, + type BillingConfig, +} from "./catalog"; import { recordBillingMetric } from "./metrics"; import { pageBillingAlert } from "./alerts"; import type { BillingProviderAdapter } from "./provider"; import { providerErrorSummary } from "./provider"; import logger from "../services/log"; +import { getBillingEngine } from "./engine"; export class BillingCatalogUnavailableError extends Error { constructor(message = "billing_catalog_unavailable") { @@ -26,26 +36,10 @@ export async function recordRequestedCatalogRevision( config: BillingConfig, ): Promise { if (config.deploymentMode !== "cloud" || !config.catalogRevision) return; - const [existing] = await db - .select({ - id: billingCatalogRevisions.id, - status: billingCatalogRevisions.status, - }) - .from(billingCatalogRevisions) - .where(eq(billingCatalogRevisions.revision, config.catalogRevision)) - .limit(1); - if (existing) return; - await db - .insert(billingCatalogRevisions) - .values({ - revision: config.catalogRevision, - checkoutProvider: config.checkoutProvider!, - status: "pending_verification", - }) - .onConflictDoNothing({ target: billingCatalogRevisions.revision }); + const recorded = await getBillingEngine().recordRequestedCatalog(); recordBillingMetric("billing.catalog.revision_recorded", { revision: config.catalogRevision, - status: "pending_verification", + status: recorded?.status ?? "pending_verification", }); } @@ -69,7 +63,7 @@ export async function getActiveCatalog(config: BillingConfig) { if (!active) throw new BillingCatalogUnavailableError(); const items = await db .select({ - catalogKey: billingCatalogRevisionItems.catalogKey, + offerKey: billingCatalogRevisionItems.offerKey, price: billingPriceEntries, }) .from(billingCatalogRevisionItems) @@ -93,8 +87,10 @@ export function checkoutIsAvailable( ): boolean { return ( config.deploymentMode === "cloud" && - activeRevision !== null && - activeRevision === config.catalogRevision + packageCheckoutIsAvailable({ + requestedRevision: config.catalogRevision, + activeRevision, + }) ); } @@ -103,13 +99,7 @@ async function verifyOffer( offer: BillingOffer, ) { const snapshot = await provider.retrieveProduct(offer.providerProductId); - if ( - snapshot.provider !== offer.provider || - snapshot.providerProductId !== offer.providerProductId || - snapshot.currency !== offer.currency || - snapshot.amountMinor !== offer.amountMinor || - snapshot.interval !== offer.interval - ) { + if (!catalogMatchesProviderSnapshot(toPackageOffer(offer), snapshot)) { throw new Error(`billing_catalog_product_mismatch:${offer.catalogKey}`); } return snapshot; @@ -134,159 +124,47 @@ async function markRevision( * disables checkout without changing entitlements. */ export async function verifyCatalogAgainstProvider( config: BillingConfig, - provider: BillingProviderAdapter, + _provider?: BillingProviderAdapter, ): Promise { if (config.deploymentMode !== "cloud" || !config.catalogRevision) return; - await recordRequestedCatalogRevision(config); - const [requested] = await db - .select() - .from(billingCatalogRevisions) - .where(eq(billingCatalogRevisions.revision, config.catalogRevision)) - .limit(1); - if (!requested) throw new BillingCatalogUnavailableError(); - if (requested.status === "abandoned") return; - - try { - for (const offer of config.offers) await verifyOffer(provider, offer); - } catch (error) { + const result = await getBillingEngine().verifyRequestedCatalog(); + if (result.mismatches.includes("revision_abandoned")) return; + if (!result.verified) { logger.error( { - error: providerErrorSummary(error), - revision: requested.revision, + mismatches: result.mismatches, + revision: result.revision, }, "billing catalog verification failed", ); recordBillingMetric("billing.catalog.invalid", { - revision: requested.revision, + revision: result.revision, }); await pageBillingAlert({ code: "catalog_invalid", message: "The billing catalog revision is invalid; checkout is frozen.", - details: { count: 1 }, + details: { + count: result.mismatches.length, + mismatches: result.mismatches.join(","), + }, }).catch(() => undefined); - if (requested.status !== "active") { - await markRevision(requested.id, "invalid"); - } else { - await markRevision(requested.id, "invalid"); - } throw new BillingCatalogUnavailableError("billing_catalog_unavailable"); } - - await db.transaction(async (tx) => { - const [existingRevision] = await tx - .select() - .from(billingCatalogRevisions) - .where(eq(billingCatalogRevisions.id, requested.id)) - .limit(1) - .for("update"); - if (!existingRevision) throw new BillingCatalogUnavailableError(); - const [activeRevision] = await tx - .select() - .from(billingCatalogRevisions) - .where( - and( - eq( - billingCatalogRevisions.checkoutProvider, - provider.provider, - ), - eq(billingCatalogRevisions.status, "active"), - ), - ) - .limit(1) - .for("update"); - if (activeRevision && activeRevision.revision > requested.revision) { - throw new Error("billing_catalog_revision_rollback"); - } - const priceRows = []; - for (const offer of config.offers) { - const [existingPrice] = await tx - .select() - .from(billingPriceEntries) - .where( - and( - eq(billingPriceEntries.provider, offer.provider), - eq( - billingPriceEntries.providerProductId, - offer.providerProductId, - ), - ), - ) - .limit(1) - .for("update"); - if (existingPrice) { - if ( - existingPrice.amountMinor !== offer.amountMinor || - existingPrice.currency !== offer.currency || - existingPrice.billingInterval !== offer.interval || - existingPrice.plan !== offer.plan || - existingPrice.catalogKey !== offer.catalogKey - ) { - throw new Error("billing_provider_product_changed"); - } - await tx - .update(billingPriceEntries) - .set({ verifiedAt: new Date(), updatedAt: new Date() }) - .where(eq(billingPriceEntries.id, existingPrice.id)); - priceRows.push(existingPrice); - } else { - const [created] = await tx - .insert(billingPriceEntries) - .values({ - catalogKey: offer.catalogKey, - plan: offer.plan, - billingInterval: offer.interval, - currency: offer.currency, - amountMinor: offer.amountMinor, - provider: offer.provider, - providerProductId: offer.providerProductId, - verifiedAt: new Date(), - }) - .returning(); - if (!created) - throw new Error("billing_price_entry_unavailable"); - priceRows.push(created); - } - await tx - .insert(billingCatalogRevisionItems) - .values({ - catalogRevisionId: existingRevision.id, - catalogKey: offer.catalogKey, - billingPriceEntryId: priceRows[priceRows.length - 1].id, - }) - .onConflictDoNothing(); - } - if (activeRevision && activeRevision.id !== existingRevision.id) { - await tx - .update(billingCatalogRevisions) - .set({ - status: "retired", - retiredAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(billingCatalogRevisions.id, activeRevision.id)); - } - await tx - .update(billingCatalogRevisions) - .set({ - status: "active", - verifiedAt: new Date(), - activatedAt: existingRevision.activatedAt ?? new Date(), - updatedAt: new Date(), - }) - .where(eq(billingCatalogRevisions.id, existingRevision.id)); - }); recordBillingMetric("billing.catalog.activated", { - revision: requested.revision, + revision: result.revision, }); + void _provider; } -/** Checkout-time check of the selected product. A mismatch freezes the catalog. */ +/** Checkout-time check of the selected product. A mismatch must not take down + * the last verified catalog; package checkout re-checks before charging. */ export async function verifyCheckoutOffer( config: BillingConfig, provider: BillingProviderAdapter, offer: BillingOffer, ): Promise { + void config; try { await verifyOffer(provider, offer); } catch (error) { @@ -297,22 +175,7 @@ export async function verifyCheckoutOffer( }, "billing checkout catalog mismatch", ); - const [active] = await db - .select({ id: billingCatalogRevisions.id }) - .from(billingCatalogRevisions) - .where( - and( - eq( - billingCatalogRevisions.checkoutProvider, - provider.provider, - ), - eq(billingCatalogRevisions.status, "active"), - ), - ) - .limit(1); - if (active) await markRevision(active.id, "invalid"); - recordBillingMetric("billing.catalog.invalid", { - reason: "checkout_mismatch", + recordBillingMetric("billing.catalog.checkout_mismatch", { catalog_key: offer.catalogKey, }); throw new BillingCatalogUnavailableError(); @@ -321,8 +184,9 @@ export async function verifyCheckoutOffer( export async function requireActiveCatalog( config: BillingConfig, - provider: BillingProviderAdapter, + _provider: BillingProviderAdapter, ) { + void _provider; const active = await getActiveCatalog(config); if (active.revision.revision !== config.catalogRevision) { throw new Error("billing_catalog_changed"); @@ -337,6 +201,22 @@ export async function abandonCatalogRevision( const trimmed = reason.trim(); if (!trimmed || trimmed.length > 500) throw new Error("operator_reason_invalid"); + const config = readBillingConfig(); + if ( + config.deploymentMode === "cloud" && + config.catalogRevision === revision + ) { + const abandoned = await getBillingEngine().abandonRequestedCatalog({ + actorId: "operator", + reason: trimmed, + }); + recordBillingMetric("billing.catalog.abandoned", { + revision, + reason: trimmed, + status: abandoned.status, + }); + return true; + } const [row] = await db .select() .from(billingCatalogRevisions) diff --git a/apps/api/src/billing/catalog.ts b/apps/api/src/billing/catalog.ts index 24475e5..5a365ec 100644 --- a/apps/api/src/billing/catalog.ts +++ b/apps/api/src/billing/catalog.ts @@ -1,4 +1,11 @@ import { createHmac } from "node:crypto"; +import { BillingConfigurationError } from "@codelitdev/billing/core"; +import { + validateCatalog, + type BillingOffer as PackageBillingOffer, +} from "@codelitdev/billing/catalog"; + +export { BillingConfigurationError }; export const billingCatalogKeys = [ "pro_month", @@ -34,11 +41,18 @@ export type BillingConfig = { offers: BillingOffer[]; }; -export class BillingConfigurationError extends Error { - constructor(message: string) { - super(`billing_configuration_invalid:${message}`); - this.name = "BillingConfigurationError"; - } +export function toPackageOffer(offer: BillingOffer): PackageBillingOffer { + return { + key: offer.catalogKey, + revision: offer.catalogRevision, + plan: offer.plan, + interval: offer.interval, + currency: offer.currency, + amountMinor: offer.amountMinor, + provider: offer.provider, + providerProductId: offer.providerProductId, + providerTrialDays: offer.trialDays, + }; } const offerEnv: Record< @@ -215,6 +229,13 @@ export function readBillingConfig( throw new BillingConfigurationError("provider_products_must_be_unique"); } + validateCatalog({ + offers: offers.map(toPackageOffer), + requiredOfferKeys: billingCatalogKeys, + revision, + checkoutProvider, + }); + return { deploymentMode: "cloud", checkoutProvider, diff --git a/apps/api/src/billing/checkout.ts b/apps/api/src/billing/checkout.ts index 379de08..c8a4183 100644 --- a/apps/api/src/billing/checkout.ts +++ b/apps/api/src/billing/checkout.ts @@ -1,18 +1,6 @@ import { and, eq, inArray } from "drizzle-orm"; -import { randomUUID } from "node:crypto"; import { db } from "../db/client"; -import { - billingCheckoutAttempts, - billingPriceEntries, - billingProviderCustomers, - billingTrialClaims, - organizationMembers, - organizationPlanStates, - organizationSubscriptions, - organizations, - user, -} from "../db/schema"; -import { encryptBillingValue, decryptBillingValue } from "./crypto"; +import { billingTrialClaims, organizations, user } from "../db/schema"; import { fingerprintVerifiedEmail, getBillingOffer, @@ -21,8 +9,13 @@ import { } from "./catalog"; import { requireActiveCatalog, verifyCheckoutOffer } from "./catalog-store"; import { getBillingProvider } from "./provider-registry"; -import { BillingProviderError, providerErrorSummary } from "./provider"; import { createOrganization } from "../organization/queries"; +import { getBillingEngine, preconsumedGrant } from "./engine"; +import { + BillingWorkflowError, + retainsPaidEntitlement, +} from "@codelitdev/billing/core"; +import type { BillingActionGrant } from "@codelitdev/billing/workflows"; export class BillingCheckoutError extends Error { constructor( @@ -35,6 +28,7 @@ export class BillingCheckoutError extends Error { | "active_subscription_exists" | "billing_checkout_pending" | "organization_name_already_exists" + | "pending_organization_exists" | "payment_required", public readonly status: 400 | 401 | 402 | 403 | 409 | 503, public readonly details: Record = {}, @@ -44,6 +38,46 @@ export class BillingCheckoutError extends Error { } } +function mapCheckoutWorkflowError( + error: unknown, + details: Record = {}, +): BillingCheckoutError { + if (error instanceof BillingCheckoutError) return error; + const code = + error instanceof BillingWorkflowError + ? error.code + : "provider_unavailable"; + switch (code) { + case "catalog_changed": + return new BillingCheckoutError( + "billing_catalog_changed", + 409, + details, + ); + case "catalog_unavailable": + return new BillingCheckoutError( + "billing_catalog_unavailable", + 503, + details, + ); + case "active_subscription_exists": + return new BillingCheckoutError("active_subscription_exists", 409); + case "checkout_pending": + return new BillingCheckoutError("billing_checkout_pending", 409); + case "payer_mismatch": + case "grant_invalid": + case "grant_consumed": + return new BillingCheckoutError("billing_owner_required", 403); + case "subscription_required": + return new BillingCheckoutError("payment_required", 402); + default: + return new BillingCheckoutError( + "billing_provider_unavailable", + 503, + ); + } +} + function errorDetails(config: ReturnType) { return { catalogRevision: config.catalogRevision, @@ -56,10 +90,6 @@ function errorDetails(config: ReturnType) { }; } -function expiration(now = new Date()): Date { - return new Date(now.getTime() + 24 * 60 * 60 * 1000); -} - async function reserveTrialInTransaction( tx: Parameters[0]>[0], input: { @@ -145,8 +175,7 @@ function returnUrl(organizationPublicId: string): string { // This is server-owned and intentionally has no client-supplied redirect. // Checkout returns to the dashboard origin (not the API origin), where the // UI can poll the webhook-backed billing projection. - const webClient = process.env.WEB_CLIENT; - if (!webClient) throw new Error("WEB_CLIENT_missing"); + const webClient = process.env.WEB_CLIENT || "http://localhost:3000"; const params = new URLSearchParams({ tab: "plan", billing: "confirming", @@ -155,19 +184,14 @@ function returnUrl(organizationPublicId: string): string { return `${new URL(webClient).origin}/organizations?${params.toString()}`; } -function cancelUrl(organizationPublicId: string): string { - const webClient = process.env.WEB_CLIENT; - if (!webClient) throw new Error("WEB_CLIENT_missing"); - return `${new URL(webClient).origin}/organizations?tab=plan&organization=${encodeURIComponent(organizationPublicId)}`; -} - export async function createOrganizationCheckout(input: { organizationId: string; - payerUserId: string; + payerId: string; plan: "pro" | "business"; interval: "month" | "year"; catalogRevision: number; pendingTeamName?: string; + grant?: BillingActionGrant; }) { let config: ReturnType; try { @@ -214,7 +238,7 @@ export async function createOrganizationCheckout(input: { throw new BillingCheckoutError("billing_catalog_unavailable", 503); } const price = catalog.items.find( - (row) => row.catalogKey === offer.catalogKey, + (row) => row.offerKey === offer.catalogKey, )?.price; if (!price) throw new BillingCheckoutError("billing_catalog_unavailable", 503); @@ -227,7 +251,7 @@ export async function createOrganizationCheckout(input: { emailVerified: user.emailVerified, }) .from(user) - .where(eq(user.id, input.payerUserId)) + .where(eq(user.id, input.payerId)) .limit(1); if (!identity?.emailVerified) { throw new BillingCheckoutError("billing_owner_required", 403, { @@ -242,324 +266,110 @@ export async function createOrganizationCheckout(input: { .limit(1); if (!organization) throw new BillingCheckoutError("billing_provider_unavailable", 503); + const [lockedOrganization] = await db + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1); + if ( + !lockedOrganization || + !["active", "pending_payment"].includes(lockedOrganization.status) + ) { + throw new BillingCheckoutError("billing_owner_required", 403); + } + + const billing = getBillingEngine(); const now = new Date(); - // The durable attempt row owns the provider idempotency key. Repeated - // requests while an attempt is open return that attempt's URL; a fresh - // random suffix is generated only after the previous attempt is terminal. - const attemptKeyPrefix = `checkout:${input.organizationId}:${input.payerUserId}:${offer.catalogKey}:${config.catalogRevision}`; - const pending = await db.transaction(async (tx) => { - const [observedPlanState] = await tx - .select() - .from(organizationPlanStates) - .where( - eq(organizationPlanStates.organizationId, input.organizationId), - ) - .limit(1); - let subscription: - | Pick< - typeof organizationSubscriptions.$inferSelect, - "id" | "status" | "paidThroughAt" | "cancelAtPeriodEnd" - > - | undefined; - if (observedPlanState?.activeSubscriptionId) { - [subscription] = await tx - .select({ - id: organizationSubscriptions.id, - status: organizationSubscriptions.status, - paidThroughAt: organizationSubscriptions.paidThroughAt, - cancelAtPeriodEnd: - organizationSubscriptions.cancelAtPeriodEnd, - }) - .from(organizationSubscriptions) - .where( - eq( - organizationSubscriptions.id, - observedPlanState.activeSubscriptionId, - ), - ) - .limit(1) - .for("update"); - } - const [existing] = await tx - .select() - .from(billingCheckoutAttempts) + const existingSub = await billing.store.findEntitlementSubscription( + input.organizationId, + ); + if (existingSub && !retainsPaidEntitlement(existingSub, now)) { + existingSub.isEntitlementSource = false; + await billing.store.upsertSubscription(existingSub); + const planState = await billing.store.ensurePlanState( + input.organizationId, + ); + planState.activeSubscriptionId = null; + await billing.store.savePlanState(planState); + } + + let trialDays = 0; + if (offer.trialDays > 0) { + const secrets = trialHmacSecrets(); + const fingerprints = secrets.map((secret) => + fingerprintVerifiedEmail(identity.email, secret.secret), + ); + const [existing] = await db + .select({ + status: billingTrialClaims.status, + }) + .from(billingTrialClaims) .where( and( - eq( - billingCheckoutAttempts.organizationId, - input.organizationId, + eq(billingTrialClaims.trialKey, "pro_month"), + inArray( + billingTrialClaims.verifiedEmailFingerprint, + fingerprints, ), - inArray(billingCheckoutAttempts.status, [ - "creating", - "open", - ]), ), ) - .limit(1) - .for("update"); - const [lockedOrganization] = await tx - .select({ status: organizations.status }) - .from(organizations) - .where(eq(organizations.id, input.organizationId)) - .limit(1) - .for("update"); - if ( - !lockedOrganization || - !["active", "pending_payment"].includes(lockedOrganization.status) - ) { - throw new BillingCheckoutError("billing_owner_required", 403); - } - const [planState] = await tx - .select() - .from(organizationPlanStates) - .where( - eq(organizationPlanStates.organizationId, input.organizationId), - ) - .limit(1) - .for("update"); - if (!planState) - throw new BillingCheckoutError("billing_provider_unavailable", 503); - if ( - planState.activeSubscriptionId !== - (observedPlanState?.activeSubscriptionId ?? null) - ) { - throw new BillingCheckoutError("billing_checkout_pending", 409); - } - if (planState.activeSubscriptionId) { - if ( - subscription && - (["pending", "trialing", "active", "past_due"].includes( - subscription.status, - ) || - Boolean( - subscription.cancelAtPeriodEnd && - subscription.paidThroughAt && - subscription.paidThroughAt > now, - )) - ) { - throw new BillingCheckoutError( - "active_subscription_exists", - 409, - ); - } - // Detach an elapsed or immediately-cancelled source in the same - // transaction that creates its replacement checkout. Otherwise a - // payment completed before the hourly expiry sweep would be - // quarantined as a conflicting live subscription. - if (subscription) { - await tx - .update(organizationSubscriptions) - .set({ isEntitlementSource: false, updatedAt: now }) - .where(eq(organizationSubscriptions.id, subscription.id)); - } - await tx - .update(organizationPlanStates) - .set({ - plan: "free", - activeSubscriptionId: null, - projectionVersion: planState.projectionVersion + 1, - updatedAt: now, - }) - .where(eq(organizationPlanStates.id, planState.id)); - } - if (existing && existing.expiresAt > now) { - if (existing.checkoutUrlEncrypted) { - return { existing }; - } - throw new BillingCheckoutError("billing_checkout_pending", 409); - } - const attemptKey = `${attemptKeyPrefix}:${randomUUID()}`; - if (existing) { - await tx - .update(billingCheckoutAttempts) - .set({ - status: "expired", - completedAt: now, - updatedAt: now, - checkoutUrlEncrypted: null, - }) - .where(eq(billingCheckoutAttempts.id, existing.id)); - } - - let [customer] = await tx - .select() - .from(billingProviderCustomers) + .limit(1); + const [existingUser] = await db + .select({ status: billingTrialClaims.status }) + .from(billingTrialClaims) .where( and( - eq(billingProviderCustomers.provider, provider.provider), - eq(billingProviderCustomers.userId, input.payerUserId), + eq(billingTrialClaims.userId, identity.id), + eq(billingTrialClaims.trialKey, "pro_month"), ), ) - .limit(1) - .for("update"); - if (!customer) { - [customer] = await tx - .insert(billingProviderCustomers) - .values({ - provider: provider.provider, - userId: input.payerUserId, - idempotencyKey: `customer:${provider.provider}:${input.payerUserId}`, - status: "creating", - }) - .onConflictDoNothing() - .returning(); - if (!customer) { - [customer] = await tx - .select() - .from(billingProviderCustomers) - .where( - and( - eq( - billingProviderCustomers.provider, - provider.provider, - ), - eq( - billingProviderCustomers.userId, - input.payerUserId, - ), - ), - ) - .limit(1) - .for("update"); - } - } - if (!customer) - throw new BillingCheckoutError("billing_provider_unavailable", 503); - const [attempt] = await tx - .insert(billingCheckoutAttempts) - .values({ - organizationId: input.organizationId, - payerUserId: input.payerUserId, - provider: provider.provider, - catalogRevision: config.catalogRevision!, - catalogKey: offer.catalogKey, - requestedPlan: offer.plan, - requestedInterval: offer.interval, - pendingTeamName: input.pendingTeamName ?? null, - billingPriceEntryId: price.id, - quotedAmountMinor: offer.amountMinor, - quotedCurrency: offer.currency, - billingCustomerId: customer.id, - idempotencyKey: attemptKey, - status: "creating", - expiresAt: expiration(now), - }) - .returning(); - if (!attempt) - throw new BillingCheckoutError("billing_provider_unavailable", 503); - let trialEligible = false; - if (offer.trialDays > 0) { - trialEligible = await reserveTrialInTransaction(tx, { - userId: identity.id, - email: identity.email, - organizationId: input.organizationId, - checkoutAttemptId: attempt.id, - expiresAt: attempt.expiresAt, - }); - } - return { - attempt, - customer, - trialDays: trialEligible ? offer.trialDays : 0, - }; - }); - - if ("existing" in pending && pending.existing) { - try { - return { - checkoutUrl: decryptBillingValue( - pending.existing.checkoutUrlEncrypted!, - ), - expiresAt: pending.existing.expiresAt.toISOString(), - }; - } catch { - throw new BillingCheckoutError("billing_checkout_pending", 409); + .limit(1); + const claimed = existing ?? existingUser; + if (!claimed || claimed.status === "released") { + trialDays = offer.trialDays; } } - const { attempt, customer, trialDays } = pending; - let customerId = customer.providerCustomerId; try { - if (!customerId) { - const created = await provider.createCustomer({ + const result = await billing.startCheckout({ + grant: + input.grant ?? + preconsumedGrant( + "checkout", + input.organizationId, + input.payerId, + ), + entity: { kind: "organization", id: input.organizationId }, + payer: { + id: identity.id, email: identity.email, - name: identity.name, - idempotencyKey: customer.idempotencyKey, - }); - customerId = created.providerCustomerId; - await db - .update(billingProviderCustomers) - .set({ - providerCustomerId: customerId, - status: "active", - updatedAt: new Date(), - lastError: null, - }) - .where(eq(billingProviderCustomers.id, customer.id)); - } - const checkout = await provider.createCheckout({ - productId: offer.providerProductId, - currency: offer.currency, - customerId, - payerEmail: identity.email, + name: identity.name || identity.email, + }, + offerKey: offer.catalogKey, + catalogRevision: input.catalogRevision, returnUrl: returnUrl(organization.organizationId), - cancelUrl: cancelUrl(organization.organizationId), - attemptId: attempt.attemptId, - catalogKey: offer.catalogKey, trialDays, - idempotencyKey: attempt.idempotencyKey, + applicationFields: { + pendingTeamName: input.pendingTeamName ?? null, + }, }); - await db - .update(billingCheckoutAttempts) - .set({ - providerCheckoutSessionId: checkout.providerCheckoutSessionId, - checkoutUrlEncrypted: encryptBillingValue(checkout.checkoutUrl), - status: "open", - updatedAt: new Date(), - }) - .where(eq(billingCheckoutAttempts.id, attempt.id)); + if (trialDays > 0) { + await db.transaction((tx) => + reserveTrialInTransaction(tx, { + userId: identity.id, + email: identity.email, + organizationId: input.organizationId, + checkoutAttemptId: result.attempt.id, + expiresAt: result.attempt.expiresAt, + }), + ); + } return { - checkoutUrl: checkout.checkoutUrl, - expiresAt: attempt.expiresAt.toISOString(), + checkoutUrl: result.checkoutUrl, + expiresAt: result.attempt.expiresAt.toISOString(), }; } catch (error) { - // Only an explicitly definitive provider rejection can safely abandon - // the attempt. Unknown/network errors may have reached the provider, - // so leave the row creating for reconciliation with the same key. - const ambiguous = - !(error instanceof BillingProviderError) || - error.code === "unavailable" || - error.code === "rate_limited"; - await db - .update(billingCheckoutAttempts) - .set({ - status: ambiguous ? "creating" : "abandoned", - completedAt: ambiguous ? null : new Date(), - lastError: providerErrorSummary(error), - updatedAt: new Date(), - }) - .where(eq(billingCheckoutAttempts.id, attempt.id)); - await db - .update(billingProviderCustomers) - .set({ - status: customerId ? "active" : "creating", - lastError: providerErrorSummary(error), - updatedAt: new Date(), - }) - .where(eq(billingProviderCustomers.id, customer.id)); - if (!ambiguous) { - await db - .update(billingTrialClaims) - .set({ status: "released", updatedAt: new Date() }) - .where( - and( - eq(billingTrialClaims.checkoutAttemptId, attempt.id), - eq(billingTrialClaims.status, "reserved"), - ), - ); - } - if (error instanceof BillingCheckoutError) throw error; - throw new BillingCheckoutError("billing_provider_unavailable", 503); + throw mapCheckoutWorkflowError(error, errorDetails(config)); } } @@ -571,121 +381,30 @@ export async function resumeOrganizationCheckoutAttempt( attemptId: string, now = new Date(), ): Promise { - const [row] = await db - .select({ - attempt: billingCheckoutAttempts, - customer: billingProviderCustomers, - organizationPublicId: organizations.organizationId, - email: user.email, - name: user.name, - price: billingPriceEntries, - }) - .from(billingCheckoutAttempts) - .innerJoin( - billingProviderCustomers, - eq( - billingProviderCustomers.id, - billingCheckoutAttempts.billingCustomerId, - ), - ) - .innerJoin( - organizations, - eq(organizations.id, billingCheckoutAttempts.organizationId), - ) - .innerJoin(user, eq(user.id, billingCheckoutAttempts.payerUserId)) - .innerJoin( - billingPriceEntries, - eq( - billingPriceEntries.id, - billingCheckoutAttempts.billingPriceEntryId, - ), - ) - .where(eq(billingCheckoutAttempts.id, attemptId)) - .limit(1); - if ( - !row || - row.attempt.status !== "creating" || - row.attempt.expiresAt <= now - ) + const billing = getBillingEngine(); + const attempt = await billing.store.findCheckoutById(attemptId); + if (!attempt || attempt.status !== "creating" || attempt.expiresAt <= now) { return false; - const provider = getBillingProvider(row.attempt.provider); - let customerId = row.customer.providerCustomerId; - try { - if (!customerId) { - const created = await provider.createCustomer({ - email: row.email, - name: row.name, - idempotencyKey: row.customer.idempotencyKey, - }); - customerId = created.providerCustomerId; - await db - .update(billingProviderCustomers) - .set({ - providerCustomerId: customerId, - status: "active", - updatedAt: now, - lastError: null, - }) - .where(eq(billingProviderCustomers.id, row.customer.id)); - } - const config = readBillingConfig(); - const configuredOffer = getBillingOffer( - config, - row.attempt.requestedPlan as "pro" | "business", - row.attempt.requestedInterval as "month" | "year", - ); - let trialDays = 0; - if (configuredOffer?.trialDays) { - const eligible = await db.transaction((tx) => - reserveTrialInTransaction(tx, { - userId: row.attempt.payerUserId, - email: row.email, - organizationId: row.attempt.organizationId, - checkoutAttemptId: row.attempt.id, - expiresAt: row.attempt.expiresAt, - }), - ); - if (eligible) trialDays = configuredOffer.trialDays; - } - const checkout = await provider.createCheckout({ - productId: row.price.providerProductId, - currency: row.price.currency, - customerId, - payerEmail: row.email, - returnUrl: returnUrl(row.organizationPublicId), - cancelUrl: cancelUrl(row.organizationPublicId), - attemptId: row.attempt.attemptId, - catalogKey: row.attempt.catalogKey, - trialDays, - idempotencyKey: row.attempt.idempotencyKey, - }); - await db - .update(billingCheckoutAttempts) - .set({ - providerCheckoutSessionId: checkout.providerCheckoutSessionId, - checkoutUrlEncrypted: encryptBillingValue(checkout.checkoutUrl), - status: "open", - updatedAt: now, - lastError: null, - }) - .where(eq(billingCheckoutAttempts.id, row.attempt.id)); - return true; - } catch (error) { - await db - .update(billingCheckoutAttempts) - .set({ lastError: providerErrorSummary(error), updatedAt: now }) - .where(eq(billingCheckoutAttempts.id, row.attempt.id)); - throw error; } + await billing.enqueueJob({ + provider: attempt.provider, + checkoutAttemptId: attempt.id, + }); + await billing.runReconciliationBatch({ + workerId: `checkout-resume:${attempt.id}`, + }); + const latest = await billing.store.findCheckoutById(attemptId); + return latest?.status === "open"; } export async function createPaidOrganizationCheckout(input: { - payerUserId: string; + payerId: string; organizationName: string; teamName: string; plan: "pro" | "business"; interval: "month" | "year"; catalogRevision: number; + grant?: BillingActionGrant; }) { let config: ReturnType; try { @@ -706,7 +425,7 @@ export async function createPaidOrganizationCheckout(input: { let organization; try { organization = await createOrganization( - input.payerUserId, + input.payerId, input.organizationName, { pendingPayment: true, @@ -726,23 +445,7 @@ export async function createPaidOrganizationCheckout(input: { error instanceof Error && error.message === "pending_organization_exists" ) { - const [existing] = await db - .select({ organization: organizations }) - .from(organizations) - .innerJoin( - organizationMembers, - eq(organizationMembers.organizationId, organizations.id), - ) - .where( - and( - eq(organizationMembers.userId, input.payerUserId), - eq(organizationMembers.role, "owner"), - eq(organizations.status, "pending_payment"), - ), - ) - .limit(1); - if (!existing?.organization) throw error; - organization = existing.organization; + throw new BillingCheckoutError("pending_organization_exists", 409); } else { throw error; } @@ -750,11 +453,12 @@ export async function createPaidOrganizationCheckout(input: { try { const checkout = await createOrganizationCheckout({ organizationId: organization.id, - payerUserId: input.payerUserId, + payerId: input.payerId, plan: input.plan, interval: input.interval, catalogRevision: input.catalogRevision, pendingTeamName: input.teamName, + grant: input.grant, }); return { ...checkout, organizationId: organization.organizationId }; } catch (error) { diff --git a/apps/api/src/billing/engine.ts b/apps/api/src/billing/engine.ts new file mode 100644 index 0000000..5116c32 --- /dev/null +++ b/apps/api/src/billing/engine.ts @@ -0,0 +1,153 @@ +import { createBilling } from "@codelitdev/billing/workflows"; +import { createDrizzleBillingStore } from "@codelitdev/billing/drizzle"; +import { createOperations } from "@codelitdev/billing/operations"; +import { systemClock } from "@codelitdev/billing/core"; +import { db } from "../db/client"; +import * as billingSchema from "../db/billing.generated"; +import { recordOrganizationAuditEvent } from "../organization/audit"; +import { encryptBillingValue, decryptBillingValue } from "./crypto"; +import { getBillingProvider } from "./provider-registry"; +import { readBillingConfig, toPackageOffer } from "./catalog"; +import { applySendLitProjectionEffects } from "./product-effects"; +import { sendlitBillingAuthorization } from "./authorization-port"; + +const clock = systemClock; + +function returnUrlAllowed(url: string): boolean { + const webClient = process.env.WEB_CLIENT || "http://localhost:3000"; + try { + return new URL(url).origin === new URL(webClient).origin; + } catch { + return false; + } +} + +const audit = { + async record(event: { + effectId: string; + actor: { kind: string; id: string }; + reason?: string; + previous: unknown; + next: unknown; + correlationIds: Record; + }) { + const organizationId = + event.correlationIds.organizationId ?? + (typeof (event.next as { billableEntityId?: string } | null) + ?.billableEntityId === "string" + ? (event.next as { billableEntityId: string }).billableEntityId + : null); + if (!organizationId) return; + await recordOrganizationAuditEvent(db, { + organizationId, + actor: { + type: event.actor.kind === "user" ? "user" : "system", + id: event.actor.kind === "user" ? event.actor.id : null, + }, + action: event.effectId.split(":")[0] ?? "billing.effect", + metadata: { + effectId: event.effectId, + reason: event.reason ?? null, + correlationIds: event.correlationIds, + }, + }); + }, +}; + +const sensitiveValues = { + async encrypt(plaintext: string) { + return { + ciphertext: encryptBillingValue(plaintext), + keyVersion: process.env.BILLING_DATA_ENCRYPTION_KEY_VERSION || "v1", + }; + }, + async decrypt( + ciphertext: string, + _context: { operatorActorId: string; reason: string }, + ) { + return decryptBillingValue(ciphertext); + }, +}; + +let engine: ReturnType | undefined; + +export function getBillingEngine() { + if (engine && !process.env.VITEST) return engine; + const config = readBillingConfig(); + const cloud = config.deploymentMode === "cloud"; + const store = createDrizzleBillingStore(db as never, { + schema: billingSchema, + clock, + planStateDefaults: { + plan: "free", + rampStage: 0, + rampCleanStageDays: 0, + }, + checkoutApplicationFields: { + toColumns: (fields) => ({ + pendingTeamName: + typeof fields.pendingTeamName === "string" + ? fields.pendingTeamName + : null, + }), + fromRow: (row) => ({ + pendingTeamName: row.pendingTeamName ?? null, + }), + }, + }); + engine = createBilling({ + database: store, + providers: cloud ? [getBillingProvider()] : [], + clock, + authorization: sendlitBillingAuthorization, + sensitiveValues, + hooks: cloud + ? { + audit, + lifecycle: { + afterProjection: (input) => + applySendLitProjectionEffects( + input, + store.getTransaction() ?? db, + ), + }, + } + : undefined, + mode: config.deploymentMode, + checkoutProvider: cloud ? (config.checkoutProvider ?? "dodo") : "", + requestedRevision: cloud ? config.catalogRevision : null, + requiredOfferKeys: cloud + ? ["pro_month", "pro_year", "business_month", "business_year"] + : [], + offers: cloud ? config.offers.map(toPackageOffer) : [], + returnUrlValidator: cloud ? returnUrlAllowed : undefined, + }); + return engine; +} + +export function getBillingOperations() { + const config = readBillingConfig(); + return createOperations({ + billing: getBillingEngine(), + clock, + requestedRevision: config.catalogRevision, + checkoutProvider: config.checkoutProvider ?? undefined, + sensitiveValues, + }); +} + +export function preconsumedGrant( + action: "checkout" | "portal" | "plan_change" | "cancellation", + organizationId: string, + actorId: string, +) { + const now = clock.now(); + return { + grantId: `preconsumed:${action}:${organizationId}:${now.getTime()}`, + actorId, + action, + target: { kind: "organization", id: organizationId }, + issuedAt: now, + expiresAt: new Date(now.getTime() + 5 * 60 * 1000), + }; +} diff --git a/apps/api/src/billing/entitlements.test.ts b/apps/api/src/billing/entitlements.test.ts index 82e93c7..ac8f016 100644 --- a/apps/api/src/billing/entitlements.test.ts +++ b/apps/api/src/billing/entitlements.test.ts @@ -10,8 +10,8 @@ import { db } from "../db/client"; import { billingProviderCustomers, billingPriceEntries, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, outboundMessages, planSendReservations, planSendUsageBuckets, @@ -154,7 +154,7 @@ describe("scheduled cancellation expiry", () => { const [price] = await tdb .insert(billingPriceEntries) .values({ - catalogKey: "pro_month", + offerKey: "pro_month", plan: "pro", billingInterval: "month", currency: "USD", @@ -167,23 +167,24 @@ describe("scheduled cancellation expiry", () => { .insert(billingProviderCustomers) .values({ provider: "dodo", - userId: account.id, + payerId: account.id, providerCustomerId: `cus_${crypto.randomUUID()}`, idempotencyKey: `customer:dodo:${account.id}`, status: "active", }) .returning(); const [subscription] = await tdb - .insert(organizationSubscriptions) + .insert(billingSubscriptions) .values({ - organizationId: organization.id, + billableEntityId: organization.id, billingCustomerId: customer.id, - billingManagerUserId: account.id, + payerId: account.id, provider: "dodo", providerSubscriptionId: `sub_${crypto.randomUUID()}`, providerProductId: price.providerProductId, billingPriceEntryId: price.id, - catalogKey: "pro_month", + catalogRevision: 1, + offerKey: "pro_month", plan: "pro", billingInterval: "month", status: "cancelled", @@ -193,12 +194,12 @@ describe("scheduled cancellation expiry", () => { }) .returning(); await tdb - .update(organizationPlanStates) + .update(billingPlanStates) .set({ plan: "pro", activeSubscriptionId: subscription.id, }) - .where(eq(organizationPlanStates.organizationId, organization.id)); + .where(eq(billingPlanStates.billableEntityId, organization.id)); return { organization, subscription }; } @@ -210,8 +211,8 @@ describe("scheduled cancellation expiry", () => { expect(await expireCancelledSubscriptionEntitlements()).toBe(0); const [state] = await tdb .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, organization.id)); + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organization.id)); expect(state).toMatchObject({ plan: "pro", activeSubscriptionId: expect.any(String), @@ -226,12 +227,12 @@ describe("scheduled cancellation expiry", () => { expect(await expireCancelledSubscriptionEntitlements()).toBe(1); const [state] = await tdb .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, organization.id)); + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organization.id)); const [row] = await tdb .select() - .from(organizationSubscriptions) - .where(eq(organizationSubscriptions.id, subscription.id)); + .from(billingSubscriptions) + .where(eq(billingSubscriptions.id, subscription.id)); expect(state).toMatchObject({ plan: "free", activeSubscriptionId: null, diff --git a/apps/api/src/billing/entitlements.ts b/apps/api/src/billing/entitlements.ts index d6e289f..4f3f90b 100644 --- a/apps/api/src/billing/entitlements.ts +++ b/apps/api/src/billing/entitlements.ts @@ -3,8 +3,8 @@ import { db } from "../db/client"; import { billingCheckoutAttempts, contacts, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, organizations, outboundMessages, planSendReservations, @@ -39,21 +39,26 @@ export async function ensureOrganizationPlanState( ) { const [existing] = await tx .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, organizationId)) + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) .limit(1) .for("update"); if (existing) return existing; const [created] = await tx - .insert(organizationPlanStates) - .values({ organizationId, plan: "free" }) - .onConflictDoNothing({ target: organizationPlanStates.organizationId }) + .insert(billingPlanStates) + .values({ + billableEntityId: organizationId, + plan: "free", + rampStage: 0, + rampCleanStageDays: 0, + }) + .onConflictDoNothing({ target: billingPlanStates.billableEntityId }) .returning(); if (created) return created; const [raced] = await tx .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, organizationId)) + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) .limit(1) .for("update"); if (!raced) throw new Error("organization_plan_state_unavailable"); @@ -66,16 +71,16 @@ export async function getOrganizationEntitlements( ): Promise { const [state] = await db .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, organizationId)) + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) .limit(1); let subscription: SubscriptionLike | null = null; if (state?.activeSubscriptionId) { const [row] = await db .select() - .from(organizationSubscriptions) - .where(eq(organizationSubscriptions.id, state.activeSubscriptionId)) + .from(billingSubscriptions) + .where(eq(billingSubscriptions.id, state.activeSubscriptionId)) .limit(1); subscription = row ? { @@ -96,7 +101,7 @@ export async function getOrganizationEntitlements( .from(billingCheckoutAttempts) .where( and( - eq(billingCheckoutAttempts.organizationId, organizationId), + eq(billingCheckoutAttempts.billableEntityId, organizationId), inArray(billingCheckoutAttempts.status, ["creating", "open"]), ), ) @@ -121,10 +126,8 @@ export async function getOrganizationEntitlementsInTransaction( const [row] = state.activeSubscriptionId ? await tx .select() - .from(organizationSubscriptions) - .where( - eq(organizationSubscriptions.id, state.activeSubscriptionId), - ) + .from(billingSubscriptions) + .where(eq(billingSubscriptions.id, state.activeSubscriptionId)) .limit(1) : []; const [pending] = await tx @@ -132,7 +135,7 @@ export async function getOrganizationEntitlementsInTransaction( .from(billingCheckoutAttempts) .where( and( - eq(billingCheckoutAttempts.organizationId, organizationId), + eq(billingCheckoutAttempts.billableEntityId, organizationId), inArray(billingCheckoutAttempts.status, ["creating", "open"]), ), ) @@ -336,14 +339,14 @@ async function advanceMarketingRamp( cleanDays = 0; } await tx - .update(organizationPlanStates) + .update(billingPlanStates) .set({ rampStage: stage, rampCleanStageDays: cleanDays, rampEvaluatedAt: now, updatedAt: now, }) - .where(eq(organizationPlanStates.id, state.id)); + .where(eq(billingPlanStates.id, state.id)); return stage; } @@ -490,15 +493,13 @@ export async function reserveSend( if (entitlements.fairUse && input.purpose === "marketing") { const [rampState] = await tx .select({ - id: organizationPlanStates.id, - rampStage: organizationPlanStates.rampStage, - rampCleanStageDays: organizationPlanStates.rampCleanStageDays, - rampEvaluatedAt: organizationPlanStates.rampEvaluatedAt, + id: billingPlanStates.id, + rampStage: billingPlanStates.rampStage, + rampCleanStageDays: billingPlanStates.rampCleanStageDays, + rampEvaluatedAt: billingPlanStates.rampEvaluatedAt, }) - .from(organizationPlanStates) - .where( - eq(organizationPlanStates.organizationId, input.organizationId), - ) + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, input.organizationId)) .limit(1) .for("update"); const stage = rampState diff --git a/apps/api/src/billing/notifications.ts b/apps/api/src/billing/notifications.ts index a0c21d8..78c79e7 100644 --- a/apps/api/src/billing/notifications.ts +++ b/apps/api/src/billing/notifications.ts @@ -3,7 +3,7 @@ import { createTransport } from "nodemailer"; import { db } from "../db/client"; import { organizationMembers, - organizationSubscriptions, + billingSubscriptions, organizations, teams, user, @@ -30,14 +30,13 @@ async function recipientsForOrganization( ): Promise { const [subscription] = await db .select({ - billingManagerUserId: - organizationSubscriptions.billingManagerUserId, + payerId: billingSubscriptions.payerId, }) - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - eq(organizationSubscriptions.organizationId, organizationId), - eq(organizationSubscriptions.isEntitlementSource, true), + eq(billingSubscriptions.billableEntityId, organizationId), + eq(billingSubscriptions.isEntitlementSource, true), ), ) .limit(1); @@ -56,11 +55,11 @@ async function recipientsForOrganization( .map((row) => row.email) .filter((email): email is string => Boolean(email)), ); - if (subscription?.billingManagerUserId) { + if (subscription?.payerId) { const [manager] = await db .select({ email: user.email }) .from(user) - .where(eq(user.id, subscription.billingManagerUserId)) + .where(eq(user.id, subscription.payerId)) .limit(1); if (manager?.email) emails.add(manager.email); } diff --git a/apps/api/src/billing/plan-change.test.ts b/apps/api/src/billing/plan-change.test.ts index 38a2166..2f2a624 100644 --- a/apps/api/src/billing/plan-change.test.ts +++ b/apps/api/src/billing/plan-change.test.ts @@ -23,10 +23,12 @@ vi.mock("./provider-registry", () => ({ import { eq } from "drizzle-orm"; import { db } from "../db/client"; import { + billingCatalogRevisionItems, + billingCatalogRevisions, billingPriceEntries, billingProviderCustomers, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, } from "../db/schema"; import { seedTeamAndContact, truncateAll, type TestDb } from "../test/db"; import { createOrganizationPlanChange } from "./plan-change"; @@ -54,6 +56,7 @@ beforeEach(async () => { catalogMocks.requireActiveCatalog.mockReset(); catalogMocks.verifyCheckoutOffer.mockReset(); catalogMocks.getBillingProvider.mockReset(); + process.env.WEB_CLIENT = "http://localhost:3000"; catalogMocks.getBillingProvider.mockReturnValue({ provider: "dodo", capabilities: { @@ -62,12 +65,20 @@ beforeEach(async () => { portalPlanChanges: false, portalIntervalChanges: false, proratedPlanChanges: true, + mutationRecovery: { + createCustomer: "idempotency_key", + createCheckout: "idempotency_key", + planChange: "idempotency_key", + cancellation: "idempotency_key", + }, }, changeSubscriptionPlan: vi.fn().mockResolvedValue({ provider: "dodo", providerPaymentId: null, paymentUrl: null, }), + retrieveProduct: vi.fn(), + createPortalSession: vi.fn(), }); await truncateAll(tdb); }); @@ -77,7 +88,7 @@ async function seedActiveSubscription() { const [currentPrice] = await tdb .insert(billingPriceEntries) .values({ - catalogKey: "pro_month", + offerKey: "pro_month", plan: "pro", billingInterval: "month", currency: "USD", @@ -89,7 +100,7 @@ async function seedActiveSubscription() { const [targetPrice] = await tdb .insert(billingPriceEntries) .values({ - catalogKey: "business_month", + offerKey: "business_month", plan: "business", billingInterval: "month", currency: "USD", @@ -98,27 +109,82 @@ async function seedActiveSubscription() { providerProductId: "pdt_business_month", }) .returning(); + const extras = await tdb + .insert(billingPriceEntries) + .values([ + { + offerKey: "pro_year", + plan: "pro", + billingInterval: "year", + currency: "USD", + amountMinor: 49000, + provider: "dodo", + providerProductId: "pdt_pro_year", + }, + { + offerKey: "business_year", + plan: "business", + billingInterval: "year", + currency: "USD", + amountMinor: 199000, + provider: "dodo", + providerProductId: "pdt_business_year", + }, + ]) + .returning(); + const [revision] = await tdb + .insert(billingCatalogRevisions) + .values({ + revision: 1, + checkoutProvider: "dodo", + status: "active", + activatedAt: new Date(), + }) + .returning(); + await tdb.insert(billingCatalogRevisionItems).values([ + { + catalogRevisionId: revision!.id, + offerKey: "pro_month", + billingPriceEntryId: currentPrice.id, + }, + { + catalogRevisionId: revision!.id, + offerKey: "business_month", + billingPriceEntryId: targetPrice.id, + }, + { + catalogRevisionId: revision!.id, + offerKey: "pro_year", + billingPriceEntryId: extras[0]!.id, + }, + { + catalogRevisionId: revision!.id, + offerKey: "business_year", + billingPriceEntryId: extras[1]!.id, + }, + ]); const [customer] = await tdb .insert(billingProviderCustomers) .values({ provider: "dodo", - userId: account.id, + payerId: account.id, providerCustomerId: `cus_${crypto.randomUUID()}`, idempotencyKey: `customer:dodo:${account.id}`, status: "active", }) .returning(); const [subscription] = await tdb - .insert(organizationSubscriptions) + .insert(billingSubscriptions) .values({ - organizationId: organization.id, + billableEntityId: organization.id, billingCustomerId: customer.id, - billingManagerUserId: account.id, + payerId: account.id, provider: "dodo", providerSubscriptionId: `sub_${crypto.randomUUID()}`, providerProductId: currentPrice.providerProductId, billingPriceEntryId: currentPrice.id, - catalogKey: "pro_month", + catalogRevision: 1, + offerKey: "pro_month", plan: "pro", billingInterval: "month", status: "active", @@ -126,17 +192,17 @@ async function seedActiveSubscription() { }) .returning(); await tdb - .update(organizationPlanStates) + .update(billingPlanStates) .set({ plan: "pro", activeSubscriptionId: subscription.id, }) - .where(eq(organizationPlanStates.organizationId, organization.id)); + .where(eq(billingPlanStates.billableEntityId, organization.id)); catalogMocks.requireActiveCatalog.mockResolvedValue({ revision: { revision: 1, status: "active" }, items: [ - { catalogKey: "pro_month", price: currentPrice }, - { catalogKey: "business_month", price: targetPrice }, + { offerKey: "pro_month", price: currentPrice }, + { offerKey: "business_month", price: targetPrice }, ], }); catalogMocks.verifyCheckoutOffer.mockResolvedValue(undefined); @@ -147,14 +213,14 @@ describe("plan-change pointer revalidation", () => { it("rejects a plan change when the active subscription pointer has been cleared", async () => { const { account, organization } = await seedActiveSubscription(); await tdb - .update(organizationPlanStates) + .update(billingPlanStates) .set({ plan: "free", activeSubscriptionId: null }) - .where(eq(organizationPlanStates.organizationId, organization.id)); + .where(eq(billingPlanStates.billableEntityId, organization.id)); await expect( createOrganizationPlanChange({ organizationId: organization.id, - actorUserId: account.id, + actorId: account.id, plan: "business", interval: "month", catalogRevision: 1, @@ -169,14 +235,14 @@ describe("plan-change pointer revalidation", () => { const { account, organization, subscription } = await seedActiveSubscription(); await tdb - .update(organizationSubscriptions) + .update(billingSubscriptions) .set({ status: "cancelled", cancelAtPeriodEnd: true }) - .where(eq(organizationSubscriptions.id, subscription.id)); + .where(eq(billingSubscriptions.id, subscription.id)); await expect( createOrganizationPlanChange({ organizationId: organization.id, - actorUserId: account.id, + actorId: account.id, plan: "business", interval: "month", catalogRevision: 1, diff --git a/apps/api/src/billing/plan-change.ts b/apps/api/src/billing/plan-change.ts index 892ec92..0bec4e7 100644 --- a/apps/api/src/billing/plan-change.ts +++ b/apps/api/src/billing/plan-change.ts @@ -1,19 +1,18 @@ -import { and, desc, eq, inArray } from "drizzle-orm"; -import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; import { db } from "../db/client"; import { - billingCatalogRevisionItems, - billingCatalogRevisions, billingPlanChangeAttempts, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, organizations, } from "../db/schema"; -import { encryptBillingValue, decryptBillingValue } from "./crypto"; +import { decryptBillingValue } from "./crypto"; import { readBillingConfig, getBillingOffer } from "./catalog"; import { requireActiveCatalog, verifyCheckoutOffer } from "./catalog-store"; import { getBillingProvider } from "./provider-registry"; -import type { BillingProviderError } from "./provider"; +import { getBillingEngine, preconsumedGrant } from "./engine"; +import { BillingWorkflowError } from "@codelitdev/billing/core"; +import type { BillingActionGrant } from "@codelitdev/billing/workflows"; type PlanChangeCode = | "billing_catalog_changed" @@ -86,41 +85,74 @@ function defaultPolicy( }; } -function providerErrorCode(error: unknown): string { - return error && typeof error === "object" && "code" in error - ? String((error as BillingProviderError).code) - : "provider_error"; -} - -async function currentCatalogRevision( - priceEntryId: string, - fallback: number, -): Promise { - const [row] = await db - .select({ revision: billingCatalogRevisions.revision }) - .from(billingCatalogRevisionItems) - .innerJoin( - billingCatalogRevisions, - eq( - billingCatalogRevisions.id, - billingCatalogRevisionItems.catalogRevisionId, - ), - ) - .where( - eq(billingCatalogRevisionItems.billingPriceEntryId, priceEntryId), - ) - .orderBy(desc(billingCatalogRevisions.revision)) - .limit(1); - return row?.revision ?? fallback; +function mapPlanChangeWorkflowError( + error: unknown, + details: Record = {}, +): BillingPlanChangeError { + if (error instanceof BillingPlanChangeError) return error; + const code = + error instanceof BillingWorkflowError + ? error.code + : "provider_unavailable"; + switch (code) { + case "catalog_changed": + return new BillingPlanChangeError( + "billing_catalog_changed", + 409, + details, + ); + case "catalog_unavailable": + return new BillingPlanChangeError( + "billing_catalog_unavailable", + 503, + ); + case "subscription_required": + return new BillingPlanChangeError( + "billing_subscription_required", + 402, + ); + case "payer_mismatch": + case "grant_invalid": + case "grant_consumed": + return new BillingPlanChangeError("billing_owner_required", 403); + case "plan_change_pending": + return new BillingPlanChangeError( + "billing_plan_change_pending", + 409, + details, + ); + case "same_offer": + return new BillingPlanChangeError( + "billing_plan_change_same_plan", + 409, + ); + case "plan_change_not_supported": + return new BillingPlanChangeError( + "billing_plan_change_not_supported", + 409, + ); + case "subscription_not_changeable": + return new BillingPlanChangeError( + "billing_subscription_not_changeable", + 409, + ); + default: + return new BillingPlanChangeError( + "billing_provider_unavailable", + 503, + details, + ); + } } export async function createOrganizationPlanChange(input: { organizationId: string; - actorUserId: string; + actorId: string; plan: "pro" | "business"; interval: "month" | "year"; catalogRevision: number; idempotencyKey?: string; + grant?: BillingActionGrant; }) { let config: ReturnType; try { @@ -169,271 +201,115 @@ export async function createOrganizationPlanChange(input: { throw new BillingPlanChangeError("billing_catalog_unavailable", 503); } const targetPrice = catalog.items.find( - (row) => row.catalogKey === offer.catalogKey, + (row) => row.offerKey === offer.catalogKey, )?.price; if (!targetPrice) throw new BillingPlanChangeError("billing_catalog_unavailable", 503); - const idempotencyKey = `plan-change:${input.organizationId}:${input.idempotencyKey?.trim() || randomUUID()}`; - const now = new Date(); - const pending = await db.transaction(async (tx) => { - const [observedState] = await tx - .select() - .from(organizationPlanStates) - .where( - eq(organizationPlanStates.organizationId, input.organizationId), - ) - .limit(1); - if (!observedState?.activeSubscriptionId) { - throw new BillingPlanChangeError( - "billing_subscription_required", - 402, - ); - } - const [subscription] = await tx - .select() - .from(organizationSubscriptions) - .where( - and( - eq( - organizationSubscriptions.id, - observedState.activeSubscriptionId, - ), - eq( - organizationSubscriptions.organizationId, - input.organizationId, - ), - ), - ) - .limit(1) - .for("update"); - if (!subscription) - throw new BillingPlanChangeError( - "billing_subscription_required", - 402, - ); - const [organization] = await tx - .select({ status: organizations.status }) - .from(organizations) - .where(eq(organizations.id, input.organizationId)) - .limit(1) - .for("update"); - if (!organization || organization.status !== "active") { - throw new BillingPlanChangeError( - "billing_subscription_not_changeable", - 409, - ); - } - const [state] = await tx - .select() - .from(organizationPlanStates) - .where( - eq(organizationPlanStates.organizationId, input.organizationId), - ) - .limit(1) - .for("update"); - if (state?.activeSubscriptionId !== subscription.id) { - throw new BillingPlanChangeError( - "billing_subscription_not_changeable", - 409, - ); - } - if (subscription.billingManagerUserId !== input.actorUserId) { - throw new BillingPlanChangeError("billing_owner_required", 403); - } - - // Idempotency is checked before validating the current projection: a - // successful request may already have moved the subscription to the - // target product by the time the client retries. - const [existingByKey] = await tx - .select() - .from(billingPlanChangeAttempts) - .where(eq(billingPlanChangeAttempts.idempotencyKey, idempotencyKey)) - .limit(1) - .for("update"); - if (existingByKey) { - if ( - existingByKey.organizationId !== input.organizationId || - existingByKey.targetPlan !== input.plan || - existingByKey.targetInterval !== input.interval - ) { - throw new BillingPlanChangeError( - "billing_plan_change_pending", - 409, - ); - } - return { row: existingByKey, subscription }; - } - - if (!["trialing", "active"].includes(subscription.status)) { - throw new BillingPlanChangeError( - "billing_subscription_not_changeable", - 409, - ); - } - const currentPlan = subscription.plan as "pro" | "business"; - const currentInterval = subscription.billingInterval as - "month" | "year"; - - if (currentPlan === input.plan && currentInterval === input.interval) { - throw new BillingPlanChangeError( - "billing_plan_change_same_plan", - 409, - ); - } - if ( - !provider.capabilities.planChanges || - (currentInterval !== input.interval && - !provider.capabilities.intervalChanges) - ) { - throw new BillingPlanChangeError( - "billing_plan_change_not_supported", - 409, - ); - } - - const [existing] = await tx - .select() - .from(billingPlanChangeAttempts) - .where( - and( - eq( - billingPlanChangeAttempts.organizationId, - input.organizationId, - ), - inArray(billingPlanChangeAttempts.status, [ - "creating", - "pending", - ]), - ), - ) - .limit(1) - .for("update"); - if (existing) - throw new BillingPlanChangeError( - "billing_plan_change_pending", - 409, - { changeId: existing.changeId }, - ); - - const policy = defaultPolicy( - currentPlan, - currentInterval, - input.plan, - input.interval, + const [observedState] = await db + .select() + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, input.organizationId)) + .limit(1); + if (!observedState?.activeSubscriptionId) { + throw new BillingPlanChangeError("billing_subscription_required", 402); + } + const [subscription] = await db + .select() + .from(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.id, observedState.activeSubscriptionId), + eq(billingSubscriptions.billableEntityId, input.organizationId), + ), + ) + .limit(1); + if (!subscription) + throw new BillingPlanChangeError("billing_subscription_required", 402); + const [organization] = await db + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, input.organizationId)) + .limit(1); + if (!organization || organization.status !== "active") { + throw new BillingPlanChangeError( + "billing_subscription_not_changeable", + 409, ); - if ( - policy.prorationMode === "prorated_immediately" && - !provider.capabilities.proratedPlanChanges - ) { - throw new BillingPlanChangeError( - "billing_plan_change_not_supported", - 409, - ); - } - const [row] = await tx - .insert(billingPlanChangeAttempts) - .values({ - organizationId: input.organizationId, - subscriptionId: subscription.id, - actorUserId: input.actorUserId, - provider: subscription.provider, - idempotencyKey, - currentCatalogRevision: await currentCatalogRevision( - subscription.billingPriceEntryId, - config.catalogRevision!, - ), - currentBillingPriceEntryId: subscription.billingPriceEntryId, - currentPlan, - currentInterval, - targetCatalogRevision: config.catalogRevision!, - targetBillingPriceEntryId: targetPrice.id, - targetPlan: input.plan, - targetInterval: input.interval, - effectiveAt: policy.effectiveAt, - prorationMode: policy.prorationMode, - status: "creating", - requestedAt: now, - }) - .returning(); - if (!row) - throw new BillingPlanChangeError( - "billing_provider_unavailable", - 503, - ); - return { row, subscription }; - }); - - if (pending.row.status !== "creating") { - return responseFor( - pending.row, - pending.row.actorUserId === input.actorUserId, + } + if (subscription.payerId !== input.actorId) { + throw new BillingPlanChangeError("billing_owner_required", 403); + } + if (!["trialing", "active"].includes(subscription.status)) { + throw new BillingPlanChangeError( + "billing_subscription_not_changeable", + 409, ); } - - let result; - try { - result = await provider.changeSubscriptionPlan({ - providerSubscriptionId: pending.subscription.providerSubscriptionId, - targetProviderProductId: offer.providerProductId, - effectiveAt: pending.row.effectiveAt as - "immediately" | "next_billing_date", - prorationMode: pending.row.prorationMode as - "prorated_immediately" | "do_not_bill", - idempotencyKey: pending.row.idempotencyKey, - }); - } catch (error) { - const code = providerErrorCode(error); - const ambiguous = code === "unavailable" || code === "rate_limited"; - const [updated] = await db - .update(billingPlanChangeAttempts) - .set({ - status: ambiguous ? "pending" : "failed", - lastError: code.slice(0, 120), - completedAt: ambiguous ? null : new Date(), - updatedAt: new Date(), - }) - .where(eq(billingPlanChangeAttempts.id, pending.row.id)) - .returning(); - if (ambiguous) { - // The request may have reached the provider even though the HTTP - // response was lost. Return the durable pending attempt and let - // reconciliation retry with the same idempotency key. - return responseFor(updated ?? pending.row, true); - } + const currentPlan = subscription.plan as "pro" | "business"; + const currentInterval = subscription.billingInterval as "month" | "year"; + if (currentPlan === input.plan && currentInterval === input.interval) { + throw new BillingPlanChangeError("billing_plan_change_same_plan", 409); + } + if ( + !provider.capabilities.planChanges || + (currentInterval !== input.interval && + !provider.capabilities.intervalChanges) + ) { throw new BillingPlanChangeError( "billing_plan_change_not_supported", 409, - { - reason: code, - }, ); } - + const policy = defaultPolicy( + currentPlan, + currentInterval, + input.plan, + input.interval, + ); + if ( + policy.prorationMode === "prorated_immediately" && + !provider.capabilities.proratedPlanChanges + ) { + throw new BillingPlanChangeError( + "billing_plan_change_not_supported", + 409, + ); + } + const billing = getBillingEngine(); try { - const [updated] = await db - .update(billingPlanChangeAttempts) - .set({ - status: "pending", - providerPaymentId: result.providerPaymentId, - paymentUrlEncrypted: result.paymentUrl - ? encryptBillingValue(result.paymentUrl) - : null, - lastError: null, - updatedAt: new Date(), - }) - .where(eq(billingPlanChangeAttempts.id, pending.row.id)) - .returning(); - if (!updated) throw new Error("billing_plan_change_update_failed"); - return responseFor(updated, true); - } catch { - // The provider mutation may already have succeeded. Leave the local - // row non-terminal so reconciliation can persist the result and the - // signed webhook can still project entitlements. - throw new BillingPlanChangeError("billing_provider_unavailable", 503, { - changeId: pending.row.changeId, - pending: true, + const attempt = await billing.startPlanChange({ + grant: + input.grant ?? + preconsumedGrant( + "plan_change", + input.organizationId, + input.actorId, + ), + entity: { kind: "organization", id: input.organizationId }, + payer: { + id: input.actorId, + email: input.actorId, + name: input.actorId, + }, + offerKey: offer.catalogKey, + catalogRevision: input.catalogRevision, + effectiveAt: policy.effectiveAt, + prorationMode: policy.prorationMode, }); + const [row] = await db + .select() + .from(billingPlanChangeAttempts) + .where(eq(billingPlanChangeAttempts.changeId, attempt.changeId)) + .limit(1); + if (!row) + throw new BillingPlanChangeError( + "billing_provider_unavailable", + 503, + ); + return responseFor(row, true); + } catch (error) { + throw mapPlanChangeWorkflowError(error); } } @@ -448,7 +324,7 @@ export async function getOrganizationPlanChange(input: { .where( and( eq( - billingPlanChangeAttempts.organizationId, + billingPlanChangeAttempts.billableEntityId, input.organizationId, ), eq(billingPlanChangeAttempts.changeId, input.changeId), @@ -456,5 +332,5 @@ export async function getOrganizationPlanChange(input: { ) .limit(1); if (!row) return null; - return responseFor(row, row.actorUserId === input.userId); + return responseFor(row, row.actorId === input.userId); } diff --git a/apps/api/src/billing/portal.ts b/apps/api/src/billing/portal.ts index f21f656..64e3a45 100644 --- a/apps/api/src/billing/portal.ts +++ b/apps/api/src/billing/portal.ts @@ -1,18 +1,15 @@ -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "../db/client"; -import { - billingProviderCustomers, - organizationPlanStates, - organizationSubscriptions, - organizations, -} from "../db/schema"; +import { organizations, user } from "../db/schema"; import { readBillingConfig } from "./catalog"; -import { getBillingProvider } from "./provider-registry"; import { BillingCheckoutError } from "./checkout"; +import { getBillingEngine, preconsumedGrant } from "./engine"; +import type { BillingActionGrant } from "@codelitdev/billing/workflows"; export async function createOrganizationPortal(input: { organizationId: string; userId: string; + grant?: BillingActionGrant; }) { let config: ReturnType; try { @@ -29,53 +26,46 @@ export async function createOrganizationPortal(input: { .limit(1); if (!organization) throw new BillingCheckoutError("billing_provider_unavailable", 503); - const [state] = await db - .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, input.organizationId)) + const [payer] = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, input.userId)) .limit(1); - if (!state?.activeSubscriptionId) - throw new BillingCheckoutError("payment_required", 402); - const [subscription] = await db - .select({ - customerId: organizationSubscriptions.billingCustomerId, - manager: organizationSubscriptions.billingManagerUserId, - provider: organizationSubscriptions.provider, - }) - .from(organizationSubscriptions) - .where( - and( - eq(organizationSubscriptions.id, state.activeSubscriptionId), - eq( - organizationSubscriptions.organizationId, - input.organizationId, - ), - ), - ) - .limit(1); - if (!subscription || subscription.manager !== input.userId) - throw new BillingCheckoutError("billing_owner_required", 403); - const [customer] = await db - .select({ - providerCustomerId: billingProviderCustomers.providerCustomerId, - }) - .from(billingProviderCustomers) - .where(eq(billingProviderCustomers.id, subscription.customerId)) - .limit(1); - if (!customer?.providerCustomerId) + if (!payer) throw new BillingCheckoutError("billing_owner_required", 403); + const webClient = process.env.WEB_CLIENT; + if (!webClient) throw new BillingCheckoutError("billing_provider_unavailable", 503); + const billing = getBillingEngine(); + const sub = await billing.store.findEntitlementSubscription( + input.organizationId, + ); + if (!sub) throw new BillingCheckoutError("payment_required", 402); try { - const provider = getBillingProvider(subscription.provider); - const webClient = process.env.WEB_CLIENT; - if (!webClient) - throw new BillingCheckoutError("billing_provider_unavailable", 503); - const portal = await provider.createPortalSession({ - customerId: customer.providerCustomerId, + const session = await billing.startPortal({ + grant: + input.grant ?? + preconsumedGrant("portal", input.organizationId, input.userId), + entity: { kind: "organization", id: input.organizationId }, + payer: { + id: payer.id, + email: payer.email, + name: payer.name || payer.email, + }, returnUrl: `${new URL(webClient).origin}/organizations?tab=plan&organization=${encodeURIComponent(organization.publicId)}`, }); - return { portalUrl: portal.portalUrl }; + return { portalUrl: session.portalUrl }; } catch (error) { if (error instanceof BillingCheckoutError) throw error; + const code = + error && typeof error === "object" && "code" in error + ? String((error as { code: string }).code) + : ""; + if (code === "payer_mismatch" || code === "grant_invalid") { + throw new BillingCheckoutError("billing_owner_required", 403); + } + if (code === "subscription_required") { + throw new BillingCheckoutError("payment_required", 402); + } throw new BillingCheckoutError("billing_provider_unavailable", 503); } } diff --git a/apps/api/src/billing/product-effects.ts b/apps/api/src/billing/product-effects.ts new file mode 100644 index 0000000..2530a25 --- /dev/null +++ b/apps/api/src/billing/product-effects.ts @@ -0,0 +1,151 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCheckoutAttempts, + billingPlanStates, + billingSubscriptions, + billingTrialClaims, + organizations, + settings, + teamDeliverySettings, + teamMembers, + teams, +} from "../db/schema"; +import { defaultTeamName } from "../organization/default-team-name"; +import { notifyPaymentPastDue } from "./notifications"; +import type { CanonicalSubscription } from "@codelitdev/billing/core"; +import type { DrizzleDb } from "@codelitdev/billing/drizzle"; +import type { PlanStateRow } from "@codelitdev/billing/workflows"; + +type EffectsDb = Pick; + +export async function applySendLitProjectionEffects( + input: { + material: boolean; + previous: CanonicalSubscription | null; + next: CanonicalSubscription; + planState: PlanStateRow; + }, + tx: EffectsDb | DrizzleDb = db, +): Promise { + if (!input.material) return; + const organizationId = input.next.billableEntityId; + const active = input.next.isEntitlementSource; + const nextPlan = active ? input.next.plan : "free"; + const run = async (client: EffectsDb) => { + const [organization] = await client + .select({ + id: organizations.id, + name: organizations.name, + status: organizations.status, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + if (!organization) return; + const [state] = await client + .select() + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) + .limit(1); + if (!state) return; + await client + .update(billingPlanStates) + .set({ + plan: nextPlan, + firstPaidActivatedAt: + active && !state.firstPaidActivatedAt + ? (input.next.providerOccurredAt ?? new Date()) + : state.firstPaidActivatedAt, + updatedAt: new Date(), + }) + .where(eq(billingPlanStates.id, state.id)); + if ( + input.next.status === "past_due" && + input.previous?.status !== "past_due" + ) { + await client + .update(billingSubscriptions) + .set({ + pastDueAt: new Date(), + graceEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + updatedAt: new Date(), + }) + .where(eq(billingSubscriptions.id, input.next.id)); + } + if (active && input.next.originCheckoutAttemptId) { + const [attempt] = await client + .select() + .from(billingCheckoutAttempts) + .where( + eq( + billingCheckoutAttempts.id, + input.next.originCheckoutAttemptId, + ), + ) + .limit(1); + if (attempt) { + await client + .update(billingTrialClaims) + .set({ + status: "redeemed", + redeemedAt: new Date(), + updatedAt: new Date(), + }) + .where( + eq(billingTrialClaims.checkoutAttemptId, attempt.id), + ); + const [existingTeam] = await client + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.organizationId, organizationId)) + .limit(1); + if (!existingTeam) { + const [team] = await client + .insert(teams) + .values({ + organizationId, + name: + attempt.pendingTeamName || + defaultTeamName(organization.name), + }) + .returning(); + if (team) { + await client + .insert(settings) + .values({ teamId: team.id }); + await client + .insert(teamDeliverySettings) + .values({ teamId: team.id }); + await client.insert(teamMembers).values({ + teamId: team.id, + userId: attempt.payerId, + role: "admin", + }); + } + } + } + } + await client + .update(organizations) + .set({ + status: + organization.status === "pending_payment" && !active + ? "pending_payment" + : "active", + updatedAt: new Date(), + }) + .where(eq(organizations.id, organizationId)); + }; + if (tx === db) { + await db.transaction((nested) => run(nested)); + } else { + await run(tx as EffectsDb); + } + if ( + input.next.status === "past_due" && + input.previous?.status !== "past_due" + ) { + await notifyPaymentPastDue(organizationId, null).catch(() => undefined); + } +} diff --git a/apps/api/src/billing/provider-contract.ts b/apps/api/src/billing/provider-contract.ts index d83764b..a5e0c4d 100644 --- a/apps/api/src/billing/provider-contract.ts +++ b/apps/api/src/billing/provider-contract.ts @@ -1,160 +1,4 @@ -import { expect } from "vitest"; -import { BillingProviderError, type BillingProviderAdapter } from "./provider"; -import { - FakeBillingProvider, - FAKE_BILLING_WEBHOOK_KEY, -} from "./providers/fake"; - -/** Shared adapter contract. Every billing provider, including the in-memory - * fake, must satisfy these canonical behaviors. */ -export async function runBillingProviderContract( - adapter: BillingProviderAdapter, - helpers: { - signWebhook: FakeBillingProvider["signWebhook"]; - simulatePayment: FakeBillingProvider["simulatePayment"]; - productId: string; - otherProductId: string; - }, -): Promise { - expect(adapter.capabilities.portalPlanChanges).toBe(false); - expect(adapter.capabilities.portalIntervalChanges).toBe(false); - - const product = await adapter.retrieveProduct(helpers.productId); - expect(product.provider).toBe(adapter.provider); - expect(product.providerProductId).toBe(helpers.productId); - expect(product.amountMinor).toBeGreaterThan(0); - expect(product.interval === "month" || product.interval === "year").toBe( - true, - ); - - await expect(adapter.retrieveProduct("pdt_unknown")).rejects.toBeInstanceOf( - BillingProviderError, - ); - - const customer = await adapter.createCustomer({ - email: "payer@example.com", - name: "Payer", - idempotencyKey: "customer:fake:1", - }); - const customerAgain = await adapter.createCustomer({ - email: "payer@example.com", - name: "Payer", - idempotencyKey: "customer:fake:1", - }); - expect(customerAgain.providerCustomerId).toBe(customer.providerCustomerId); - - const checkout = await adapter.createCheckout({ - productId: helpers.productId, - currency: product.currency, - customerId: customer.providerCustomerId, - payerEmail: "payer@example.com", - returnUrl: "https://app.test/organizations?tab=plan", - attemptId: "bca_attempt_1", - catalogKey: "pro_month", - trialDays: 14, - idempotencyKey: "checkout:fake:1", - }); - expect(checkout.checkoutUrl.startsWith("http")).toBe(true); - const checkoutAgain = await adapter.createCheckout({ - productId: helpers.productId, - currency: product.currency, - customerId: customer.providerCustomerId, - payerEmail: "payer@example.com", - returnUrl: "https://app.test/organizations?tab=plan", - attemptId: "bca_attempt_1", - catalogKey: "pro_month", - trialDays: 14, - idempotencyKey: "checkout:fake:1", - }); - expect(checkoutAgain.providerCheckoutSessionId).toBe( - checkout.providerCheckoutSessionId, - ); - - const paid = await helpers.simulatePayment( - checkout.providerCheckoutSessionId, - ); - expect(paid.status === "trialing" || paid.status === "active").toBe(true); - expect(paid.metadata.sendlitCheckoutAttemptId).toBe("bca_attempt_1"); - const retrieved = await adapter.retrieveSubscription( - paid.providerSubscriptionId, - ); - expect(retrieved.providerProductId).toBe(helpers.productId); - expect(retrieved.providerCustomerId).toBe(customer.providerCustomerId); - - const changed = await adapter.changeSubscriptionPlan({ - providerSubscriptionId: paid.providerSubscriptionId, - targetProviderProductId: helpers.otherProductId, - effectiveAt: "immediately", - prorationMode: "prorated_immediately", - idempotencyKey: "plan-change:fake:1", - }); - expect(changed.provider).toBe(adapter.provider); - const afterChange = await adapter.retrieveSubscription( - paid.providerSubscriptionId, - ); - expect(afterChange.providerProductId).toBe(helpers.otherProductId); - - const portal = await adapter.createPortalSession({ - customerId: customer.providerCustomerId, - returnUrl: "https://app.test/organizations?tab=plan", - }); - expect(portal.portalUrl.startsWith("http")).toBe(true); - - await adapter.cancelSubscription( - paid.providerSubscriptionId, - "cancel:fake:1", - ); - const cancelled = await adapter.retrieveSubscription( - paid.providerSubscriptionId, - ); - expect(cancelled.status).toBe("cancelled"); - - const signed = helpers.signWebhook( - JSON.stringify({ - type: "subscription.updated", - data: { - subscription_id: paid.providerSubscriptionId, - customer_id: customer.providerCustomerId, - product_id: helpers.otherProductId, - status: "cancelled", - }, - }), - ); - const event = await adapter.parseWebhook(signed); - expect(event.provider).toBe(adapter.provider); - expect(event.subscriptionId).toBe(paid.providerSubscriptionId); - expect(event.eventType).toBe("subscription.updated"); - - await expect( - adapter.parseWebhook({ - body: signed.body, - headers: { ...signed.headers, "webhook-signature": "v1,deadbeef" }, - }), - ).rejects.toThrow(/webhook_signature_invalid/); - - const stale = helpers.signWebhook( - JSON.stringify({ type: "subscription.updated", data: {} }), - "evt_stale", - new Date(Date.now() - 10 * 60 * 1000), - ); - await expect(adapter.parseWebhook(stale)).rejects.toThrow( - /webhook_timestamp_stale/, - ); -} - -export function createContractFake(): { - adapter: FakeBillingProvider; - helpers: Parameters[1]; -} { - const adapter = new FakeBillingProvider(FAKE_BILLING_WEBHOOK_KEY); - adapter.seedDefaultCatalog(); - return { - adapter, - helpers: { - signWebhook: adapter.signWebhook.bind(adapter), - simulatePayment: adapter.simulatePayment.bind(adapter), - productId: "pdt_pro_month", - otherProductId: "pdt_business_month", - }, - }; -} +export { + runBillingProviderContract, + createContractFake, +} from "@codelitdev/billing/testing"; diff --git a/apps/api/src/billing/provider-registry.ts b/apps/api/src/billing/provider-registry.ts index 094007d..5fad466 100644 --- a/apps/api/src/billing/provider-registry.ts +++ b/apps/api/src/billing/provider-registry.ts @@ -1,7 +1,8 @@ +import { FakeBillingProvider } from "@codelitdev/billing/providers"; +import type { BillingProviderAdapter } from "@codelitdev/billing/providers"; import { readBillingConfig } from "./catalog"; -import type { BillingProviderAdapter, BillingProviderId } from "./provider"; -import { DodoBillingProvider } from "./providers/dodo"; -import { FakeBillingProvider } from "./providers/fake"; +import type { BillingProviderId } from "./provider"; +import { createSendLitDodoProvider } from "./providers/dodo"; /** Lazily-created adapters keep provider credentials out of module import time. */ const instances = new Map(); @@ -20,7 +21,7 @@ export function getBillingProvider( let adapter: BillingProviderAdapter; switch (selected) { case "dodo": - adapter = new DodoBillingProvider(); + adapter = createSendLitDodoProvider(); break; case "fake": { if (process.env.NODE_ENV === "production") { @@ -39,3 +40,7 @@ export function getBillingProvider( instances.set(selected, adapter); return adapter; } + +export function resetBillingProviderInstances(): void { + instances.clear(); +} diff --git a/apps/api/src/billing/provider.ts b/apps/api/src/billing/provider.ts index a52d80a..332f0d0 100644 --- a/apps/api/src/billing/provider.ts +++ b/apps/api/src/billing/provider.ts @@ -1,147 +1,32 @@ /** - * Provider-neutral billing contract. Nothing outside `providers/*` should - * import a payment provider SDK or inspect its payloads/status names. + * Provider-neutral billing contract. Adapter implementations live in + * `@codelitdev/billing`; SendLit only composes them with env, org policy, + * and persistence. */ -export type BillingProviderId = "dodo" | (string & {}); - -export type BillingProductSnapshot = { - provider: BillingProviderId; - providerProductId: string; - currency: string; - amountMinor: number; - interval: "month" | "year"; -}; - -export type BillingCustomer = { - provider: BillingProviderId; - providerCustomerId: string; -}; - -export type Checkout = { - provider: BillingProviderId; - providerCheckoutSessionId: string; - checkoutUrl: string; -}; - -export type PortalSession = { - provider: BillingProviderId; - portalUrl: string; -}; - -export type SubscriptionPlanChangeInput = { - providerSubscriptionId: string; - targetProviderProductId: string; - effectiveAt: "immediately" | "next_billing_date"; - prorationMode: "prorated_immediately" | "do_not_bill"; - idempotencyKey: string; -}; - -export type SubscriptionPlanChangeResult = { - provider: BillingProviderId; - providerPaymentId: string | null; - paymentUrl: string | null; -}; - -export type SubscriptionSnapshot = { - provider: BillingProviderId; - providerCustomerId: string; - providerSubscriptionId: string; - providerProductId: string; - status: - | "pending" - | "trialing" - | "active" - | "past_due" - | "cancelled" - | "expired"; - currentPeriodStartsAt: Date | null; - currentPeriodEndsAt: Date | null; - paidThroughAt: Date | null; - trialEndsAt: Date | null; - cancelAtPeriodEnd: boolean; - occurredAt: Date; - metadata: { - sendlitCheckoutAttemptId?: string; - catalogKey?: string; - }; -}; - -export type RawWebhookRequest = { - body: string; - headers: Record; -}; - -export type CanonicalBillingEvent = { - provider: BillingProviderId; - providerEventId: string; - eventType: string; - occurredAt: Date; - subscriptionId?: string; - snapshot?: SubscriptionSnapshot; - rawPayload: unknown; -}; - -export type BillingProviderErrorCode = - | "invalid" - | "unauthorized" - | "conflict" - | "rate_limited" - | "unavailable" - | "misconfigured"; - -export class BillingProviderError extends Error { - constructor( - public readonly code: BillingProviderErrorCode, - message: string, - public readonly cause?: unknown, - ) { - super(message); - this.name = "BillingProviderError"; - } -} - -/** Provider SDK messages can contain request IDs, URLs, or response bodies. - * Persist only a stable category in billing rows/logs. */ -export function providerErrorSummary(error: unknown): string { - if (error instanceof BillingProviderError) return `provider_${error.code}`; - return "provider_error"; -} - -export interface BillingProviderAdapter { - readonly provider: BillingProviderId; - readonly capabilities: { - planChanges: boolean; - intervalChanges: boolean; - portalPlanChanges: boolean; - portalIntervalChanges: boolean; - proratedPlanChanges: boolean; +export { + BillingProviderError, + providerErrorSummary, + type SubscriptionSnapshot, + type VerifiedWebhookEnvelope, +} from "@codelitdev/billing/core"; +export { + type BillingProviderAdapter, + type BillingProductSnapshot, + type BillingCustomer, + type Checkout, + type PortalSession, + type RawWebhookRequest, + type SubscriptionPlanChangeInput, + type SubscriptionPlanChangeResult, +} from "@codelitdev/billing/providers"; + +export type BillingProviderId = "dodo" | "fake" | (string & {}); + +/** Durable webhook evidence plus an optional retrieved snapshot used by + * SendLit projection. The snapshot is never treated as part of the envelope. */ +export type CanonicalBillingEvent = + import("@codelitdev/billing/core").VerifiedWebhookEnvelope & { + snapshot?: + import("@codelitdev/billing/core").SubscriptionSnapshot | null; + rawPayload?: unknown; }; - createCustomer(input: { - email: string; - name?: string | null; - idempotencyKey: string; - }): Promise; - createCheckout(input: { - productId: string; - currency: string; - customerId: string; - payerEmail: string; - returnUrl: string; - cancelUrl?: string; - attemptId: string; - catalogKey: string; - trialDays: number; - idempotencyKey: string; - }): Promise; - createPortalSession(input: { - customerId: string; - returnUrl: string; - }): Promise; - changeSubscriptionPlan( - input: SubscriptionPlanChangeInput, - ): Promise; - retrieveProduct(id: string): Promise; - retrieveSubscription(id: string): Promise; - cancelSubscription(id: string, idempotencyKey: string): Promise; - parseWebhook(input: RawWebhookRequest): Promise; -} diff --git a/apps/api/src/billing/providers/dodo/index.test.ts b/apps/api/src/billing/providers/dodo/index.test.ts index d729404..ad79d3a 100644 --- a/apps/api/src/billing/providers/dodo/index.test.ts +++ b/apps/api/src/billing/providers/dodo/index.test.ts @@ -1,43 +1,40 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { dodoOptionsFromEnv } from "./index"; -vi.mock("dodopayments", () => ({ - default: class FakeDodoClient { - webhooks = { - unwrap(body: string) { - return JSON.parse(body); - }, - }; - }, -})); - -import { DodoBillingProvider } from "./index"; - -describe("Dodo webhook parsing", () => { - it("accepts a valid delivery whose provider event timestamp is delayed", async () => { - const provider = new DodoBillingProvider({ +describe("SendLit Dodo env mapping", () => { + it("maps current and unexpired previous webhook keys", () => { + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const options = dodoOptionsFromEnv({ DODO_PAYMENTS_API_KEY: "test-token", - DODO_PAYMENTS_WEBHOOK_KEY_CURRENT: "whsec_test", DODO_PAYMENTS_ENVIRONMENT: "test_mode", + DODO_PAYMENTS_WEBHOOK_KEY_CURRENT: "whsec_current", + DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS: "whsec_previous", + DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT: + expiresAt.toISOString(), }); - const event = await provider.parseWebhook({ - body: JSON.stringify({ - type: "subscription.updated", - // Provider event timestamps can be delayed on retry. The - // Standard Webhooks delivery timestamp is verified by unwrap. - timestamp: "2026-08-28T00:00:00.000Z", - data: { - subscription_id: "sub_test", - status: "active", - product_id: "pdt_test", - }, - }), - headers: { - "webhook-id": "msg_test", - "webhook-timestamp": String(Math.floor(Date.now() / 1000)), - "webhook-signature": "v1,verified", - }, + expect(options.apiKey).toBe("test-token"); + expect(options.environment).toBe("test_mode"); + expect(options.webhookSecrets.map((row) => row.version)).toEqual([ + "current", + "previous", + ]); + expect(options.webhookSecrets[1]?.expiresAt?.toISOString()).toBe( + expiresAt.toISOString(), + ); + }); + + it("does not include an expired previous key", () => { + const options = dodoOptionsFromEnv({ + DODO_PAYMENTS_API_KEY: "test-token", + DODO_PAYMENTS_ENVIRONMENT: "live_mode", + DODO_PAYMENTS_WEBHOOK_KEY_CURRENT: "whsec_current", + DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS: "whsec_previous", + DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT: new Date( + Date.now() - 1000, + ).toISOString(), }); - expect(event.subscriptionId).toBe("sub_test"); - expect(event.occurredAt.toISOString()).toBe("2026-08-28T00:00:00.000Z"); + expect(options.environment).toBe("live_mode"); + expect(options.webhookSecrets).toHaveLength(1); + expect(options.webhookSecrets[0]?.secret).toBe("whsec_current"); }); }); diff --git a/apps/api/src/billing/providers/dodo/index.ts b/apps/api/src/billing/providers/dodo/index.ts index ba567d5..634dcad 100644 --- a/apps/api/src/billing/providers/dodo/index.ts +++ b/apps/api/src/billing/providers/dodo/index.ts @@ -1,430 +1,55 @@ -import DodoPayments from "dodopayments"; -import type { - BillingCustomer, - BillingProductSnapshot, - BillingProviderAdapter, - CanonicalBillingEvent, - Checkout, - PortalSession, - RawWebhookRequest, - SubscriptionPlanChangeInput, - SubscriptionPlanChangeResult, - SubscriptionSnapshot, -} from "../../provider"; -import { BillingProviderError } from "../../provider"; - -function providerError(error: unknown): BillingProviderError { - const status = Number((error as { status?: number })?.status ?? 0); - const message = - error instanceof Error ? error.message : "provider request failed"; - const code = - status === 401 || status === 403 - ? "unauthorized" - : status === 409 - ? "conflict" - : status === 429 - ? "rate_limited" - : status >= 400 && status < 500 - ? "invalid" - : status >= 500 || status === 0 - ? "unavailable" - : "unavailable"; - return new BillingProviderError(code, message, error); -} - -function dateOrNull(value: unknown): Date | null { - if (typeof value !== "string" || !value) return null; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} - -function metadataValue(metadata: unknown, key: string): string | undefined { - if (!metadata || typeof metadata !== "object") return undefined; - const value = (metadata as Record)[key]; - return typeof value === "string" ? value : undefined; -} - -function normalizeSubscription( - data: any, - eventType: string, - occurredAt: Date, -): SubscriptionSnapshot { - const sourceStatus = String(data?.status ?? "pending").toLowerCase(); - const status: SubscriptionSnapshot["status"] = - sourceStatus === "active" - ? "active" - : sourceStatus === "on_hold" || sourceStatus === "paused" - ? "past_due" - : sourceStatus === "cancelled" - ? "cancelled" - : sourceStatus === "expired" || sourceStatus === "failed" - ? "expired" - : sourceStatus === "pending" - ? "pending" - : eventType === "subscription.on_hold" - ? "past_due" - : "pending"; - const trialEndsAt = dateOrNull(data?.trial_end ?? data?.trial_ends_at); - if (status === "active" && trialEndsAt && trialEndsAt > occurredAt) { - return { - provider: "dodo", - providerCustomerId: String( - data?.customer?.customer_id ?? data?.customer_id ?? "", - ), - providerSubscriptionId: String( - data?.subscription_id ?? data?.id ?? "", - ), - providerProductId: String(data?.product_id ?? ""), - status: "trialing", - currentPeriodStartsAt: dateOrNull( - data?.previous_billing_date ?? data?.current_period_start, - ), - currentPeriodEndsAt: dateOrNull( - data?.next_billing_date ?? data?.current_period_end, - ), - paidThroughAt: dateOrNull( - data?.next_billing_date ?? data?.current_period_end, - ), - trialEndsAt, - cancelAtPeriodEnd: Boolean(data?.cancel_at_next_billing_date), - occurredAt, - metadata: { - sendlitCheckoutAttemptId: metadataValue( - data?.metadata, - "sendlitCheckoutAttemptId", - ), - catalogKey: metadataValue(data?.metadata, "catalogKey"), - }, - }; +import { systemClock, type Clock } from "@codelitdev/billing/core"; +import { + createDodoBillingProvider, + DodoBillingProvider, +} from "@codelitdev/billing/providers/dodo"; +import type { DodoBillingProviderOptions } from "@codelitdev/billing/providers"; + +export { createDodoBillingProvider, DodoBillingProvider }; + +/** Parse SendLit process env into the package's explicit Dodo options. + * The package never reads process.env itself. */ +export function dodoOptionsFromEnv( + env: NodeJS.ProcessEnv = process.env, + clock: Clock = systemClock, +): DodoBillingProviderOptions { + const apiKey = env.DODO_PAYMENTS_API_KEY?.trim() ?? ""; + const environment = env.DODO_PAYMENTS_ENVIRONMENT; + const current = env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT?.trim() ?? ""; + const webhookSecrets: DodoBillingProviderOptions["webhookSecrets"] = [ + { version: "current", secret: current }, + ]; + const previous = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS?.trim(); + const expires = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT; + if (previous && expires) { + const expiry = new Date(expires); + const max = Date.now() + 48 * 60 * 60 * 1000; + if ( + !Number.isNaN(expiry.getTime()) && + expiry.getTime() > Date.now() && + expiry.getTime() <= max + ) { + webhookSecrets.push({ + version: "previous", + secret: previous, + expiresAt: expiry, + }); + } } return { - provider: "dodo", - providerCustomerId: String( - data?.customer?.customer_id ?? data?.customer_id ?? "", - ), - providerSubscriptionId: String(data?.subscription_id ?? data?.id ?? ""), - providerProductId: String(data?.product_id ?? ""), - status, - currentPeriodStartsAt: dateOrNull( - data?.previous_billing_date ?? data?.current_period_start, - ), - currentPeriodEndsAt: dateOrNull( - data?.next_billing_date ?? data?.current_period_end, - ), - paidThroughAt: dateOrNull( - data?.next_billing_date ?? - data?.current_period_end ?? - data?.expires_at, - ), - trialEndsAt, - cancelAtPeriodEnd: Boolean(data?.cancel_at_next_billing_date), - occurredAt, - metadata: { - sendlitCheckoutAttemptId: metadataValue( - data?.metadata, - "sendlitCheckoutAttemptId", - ), - catalogKey: metadataValue(data?.metadata, "catalogKey"), - }, + apiKey, + environment: + environment === "live_mode" || environment === "test_mode" + ? environment + : "test_mode", + webhookSecrets, + clock, }; } -export class DodoBillingProvider implements BillingProviderAdapter { - readonly provider = "dodo" as const; - readonly capabilities = { - planChanges: true, - intervalChanges: true, - // Plan changes are intentionally initiated by SendLit. Keep these - // false even if a Dodo portal configuration later exposes them. - portalPlanChanges: false, - portalIntervalChanges: false, - proratedPlanChanges: true, - } as const; - private readonly client: DodoPayments; - private readonly webhookKeys: Array<{ version: string; key: string }>; - - constructor(env: NodeJS.ProcessEnv = process.env) { - const token = env.DODO_PAYMENTS_API_KEY?.trim(); - if (!token) throw new Error("DODO_PAYMENTS_API_KEY_missing"); - const environment = env.DODO_PAYMENTS_ENVIRONMENT; - if (environment !== "test_mode" && environment !== "live_mode") { - throw new Error("DODO_PAYMENTS_ENVIRONMENT_invalid"); - } - this.client = new DodoPayments({ - bearerToken: token, - environment, - timeout: 10_000, - maxRetries: 0, - webhookKey: env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT ?? null, - }); - const current = env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT?.trim(); - if (!current) - throw new Error("DODO_PAYMENTS_WEBHOOK_KEY_CURRENT_missing"); - this.webhookKeys = [{ version: "current", key: current }]; - const previous = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS?.trim(); - const expires = env.DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT; - if (previous && expires) { - const expiry = new Date(expires); - const max = Date.now() + 48 * 60 * 60 * 1000; - if ( - !Number.isNaN(expiry.getTime()) && - expiry.getTime() > Date.now() && - expiry.getTime() <= max - ) { - this.webhookKeys.push({ version: "previous", key: previous }); - } - } - } - - async createCustomer(input: { - email: string; - name?: string | null; - idempotencyKey: string; - }): Promise { - try { - const customer = await this.client.customers.create( - { email: input.email, name: input.name || input.email }, - { idempotencyKey: input.idempotencyKey }, - ); - return { - provider: this.provider, - providerCustomerId: customer.customer_id, - }; - } catch (error) { - throw providerError(error); - } - } - - async createCheckout(input: { - productId: string; - currency: string; - customerId: string; - payerEmail: string; - returnUrl: string; - cancelUrl?: string; - attemptId: string; - catalogKey: string; - trialDays: number; - idempotencyKey: string; - }): Promise { - try { - const response = await this.client.checkoutSessions.create( - { - product_cart: [ - { product_id: input.productId, quantity: 1 }, - ], - customer: { customer_id: input.customerId }, - billing_currency: input.currency as any, - return_url: input.returnUrl, - cancel_url: input.cancelUrl, - metadata: { - sendlitCheckoutAttemptId: input.attemptId, - catalogKey: input.catalogKey, - }, - subscription_data: - input.trialDays > 0 - ? { trial_period_days: input.trialDays } - : undefined, - }, - { idempotencyKey: input.idempotencyKey }, - ); - if (!response.checkout_url) - throw new Error("provider_checkout_url_missing"); - return { - provider: this.provider, - providerCheckoutSessionId: response.session_id, - checkoutUrl: response.checkout_url, - }; - } catch (error) { - throw providerError(error); - } - } - - async createPortalSession(input: { - customerId: string; - returnUrl: string; - }): Promise { - try { - const response = await this.client.customers.customerPortal.create( - input.customerId, - { - return_url: input.returnUrl, - send_email: false, - }, - ); - return { provider: this.provider, portalUrl: response.link }; - } catch (error) { - throw providerError(error); - } - } - - async changeSubscriptionPlan( - input: SubscriptionPlanChangeInput, - ): Promise { - try { - const response = await this.client.subscriptions.changePlan( - input.providerSubscriptionId, - { - product_id: input.targetProviderProductId, - quantity: 1, - effective_at: input.effectiveAt, - proration_billing_mode: input.prorationMode, - // A failed immediate charge must leave the current plan in - // place; entitlements are only changed by the webhook. - on_payment_failure: "prevent_change", - }, - { idempotencyKey: input.idempotencyKey }, - ); - return { - provider: this.provider, - providerPaymentId: response.payment_id ?? null, - paymentUrl: response.payment_link ?? null, - }; - } catch (error) { - throw providerError(error); - } - } - - private async withReadRetry(fn: () => Promise): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - return await fn(); - } catch (error) { - lastError = error; - const mapped = providerError(error); - if ( - mapped.code !== "unavailable" && - mapped.code !== "rate_limited" - ) { - throw mapped; - } - await new Promise((resolve) => - setTimeout( - resolve, - 100 * 2 ** attempt + Math.random() * 50, - ), - ); - } - } - throw providerError(lastError); - } - - async retrieveProduct(id: string): Promise { - try { - const product: any = await this.withReadRetry(() => - this.client.products.retrieve(id), - ); - const price: any = product.price; - if (!price || price.type !== "recurring_price") - throw new Error("provider_product_not_recurring"); - // Dodo exposes both the recurring payment cadence and the overall - // subscription term. The plan interval is the cadence customers - // are charged on; a product may bill monthly while its term is - // configured as a longer period. - const interval = String( - price.payment_frequency_interval ?? - price.subscription_period_interval, - ).toLowerCase(); - if (interval !== "month" && interval !== "year") - throw new Error("provider_product_interval_invalid"); - const amountMinor = Number(price.price); - if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) - throw new Error("provider_product_amount_invalid"); - return { - provider: this.provider, - providerProductId: product.product_id, - currency: String(price.currency).toUpperCase(), - amountMinor, - interval, - }; - } catch (error) { - if (error instanceof BillingProviderError) throw error; - if ( - error instanceof Error && - error.message.startsWith("provider_product_") - ) { - throw new BillingProviderError("invalid", error.message, error); - } - throw providerError(error); - } - } - - async retrieveSubscription(id: string): Promise { - try { - const subscription: any = await this.withReadRetry(() => - this.client.subscriptions.retrieve(id), - ); - return normalizeSubscription( - subscription, - "subscription.updated", - new Date(), - ); - } catch (error) { - throw providerError(error); - } - } - - async cancelSubscription( - id: string, - idempotencyKey: string, - ): Promise { - try { - await this.client.subscriptions.update( - id, - { - status: "cancelled", - cancel_at_next_billing_date: false, - cancel_reason: "cancelled_by_merchant", - }, - { idempotencyKey }, - ); - } catch (error) { - throw providerError(error); - } - } - - async parseWebhook( - input: RawWebhookRequest, - ): Promise { - const eventId = - input.headers["webhook-id"] ?? input.headers["Webhook-Id"]; - if (!eventId) throw new Error("webhook_id_missing"); - let event: any; - let verifiedVersion: string | undefined; - for (const candidate of this.webhookKeys) { - try { - event = this.client.webhooks.unwrap(input.body, { - headers: input.headers, - key: candidate.key, - }); - verifiedVersion = candidate.version; - break; - } catch { - // Try the rotation key, if it is still within its bounded window. - } - } - if (!event || !verifiedVersion) - throw new Error("webhook_signature_invalid"); - const occurredAt = dateOrNull(event.timestamp); - if (!occurredAt) throw new Error("webhook_timestamp_missing"); - // `client.webhooks.unwrap` verifies the Standard Webhooks delivery - // timestamp against Dodo's replay window. The provider event's own - // timestamp describes when the subscription changed and can be much - // older on delayed/retried deliveries, so it must not be used as a - // second freshness gate. - const isSubscription = String(event.type).startsWith("subscription."); - const snapshot = isSubscription - ? normalizeSubscription(event.data, event.type, occurredAt) - : undefined; - return { - provider: this.provider, - providerEventId: eventId, - eventType: String(event.type), - occurredAt, - subscriptionId: snapshot?.providerSubscriptionId, - snapshot, - rawPayload: { ...event, _verifiedKeyVersion: verifiedVersion }, - }; - } +export function createSendLitDodoProvider( + env: NodeJS.ProcessEnv = process.env, + clock: Clock = systemClock, +): DodoBillingProvider { + return createDodoBillingProvider(dodoOptionsFromEnv(env, clock)); } diff --git a/apps/api/src/billing/providers/fake/index.test.ts b/apps/api/src/billing/providers/fake/index.test.ts index d60e0d1..5535375 100644 --- a/apps/api/src/billing/providers/fake/index.test.ts +++ b/apps/api/src/billing/providers/fake/index.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { BillingProviderError } from "../../provider"; import { createContractFake, runBillingProviderContract, @@ -19,7 +18,7 @@ describe("fake billing provider", () => { email: "payer@example.com", idempotencyKey: "customer:outage", }); - adapter.nextFailure = new BillingProviderError("unavailable", "down"); + adapter.controls.outage = true; await expect( adapter.createCheckout({ productId: "pdt_pro_month", @@ -33,6 +32,7 @@ describe("fake billing provider", () => { idempotencyKey: "checkout:outage", }), ).rejects.toMatchObject({ code: "unavailable" }); + adapter.controls.outage = false; const checkout = await adapter.createCheckout({ productId: "pdt_pro_month", currency: "USD", diff --git a/apps/api/src/billing/providers/fake/index.ts b/apps/api/src/billing/providers/fake/index.ts index 3490f3b..a594e60 100644 --- a/apps/api/src/billing/providers/fake/index.ts +++ b/apps/api/src/billing/providers/fake/index.ts @@ -1,389 +1,4 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; -import type { - BillingCustomer, - BillingProductSnapshot, - BillingProviderAdapter, - CanonicalBillingEvent, - Checkout, - PortalSession, - RawWebhookRequest, - SubscriptionPlanChangeInput, - SubscriptionPlanChangeResult, - SubscriptionSnapshot, -} from "../../provider"; -import { BillingProviderError } from "../../provider"; - -export const FAKE_BILLING_WEBHOOK_KEY = "whsec_fake_test_key"; - -type StoredCheckout = Checkout & { - productId: string; - customerId: string; - attemptId: string; - catalogKey: string; - trialDays: number; - idempotencyKey: string; -}; - -const WEBHOOK_MAX_AGE_SECONDS = 5 * 60; - -function hmac(key: string, payload: string): string { - return createHmac("sha256", key).update(payload, "utf8").digest("hex"); -} - -function equalHex(left: string, right: string): boolean { - const a = Buffer.from(left); - const b = Buffer.from(right); - return a.length === b.length && timingSafeEqual(a, b); -} - -/** In-memory billing provider for domain tests. It speaks SendLit canonical - * types only — no Dodo SDK, product IDs as opaque strings. */ -export class FakeBillingProvider implements BillingProviderAdapter { - readonly provider = "fake" as const; - readonly capabilities = { - planChanges: true, - intervalChanges: true, - portalPlanChanges: false, - portalIntervalChanges: false, - proratedPlanChanges: true, - } as const; - - private readonly webhookKey: string; - private products = new Map(); - private customersByKey = new Map(); - private customersById = new Map(); - private checkoutsByKey = new Map(); - private checkoutsById = new Map(); - private subscriptions = new Map(); - private nextId = 1; - nextFailure: BillingProviderError | null = null; - - constructor(webhookKey = FAKE_BILLING_WEBHOOK_KEY) { - this.webhookKey = webhookKey; - } - - seedProduct(product: BillingProductSnapshot): void { - this.products.set(product.providerProductId, { - ...product, - provider: this.provider, - }); - } - - seedDefaultCatalog(): void { - const rows: Array< - Pick< - BillingProductSnapshot, - "providerProductId" | "amountMinor" | "interval" - > - > = [ - { - providerProductId: "pdt_pro_month", - amountMinor: 4900, - interval: "month", - }, - { - providerProductId: "pdt_pro_year", - amountMinor: 49000, - interval: "year", - }, - { - providerProductId: "pdt_business_month", - amountMinor: 19900, - interval: "month", - }, - { - providerProductId: "pdt_business_year", - amountMinor: 199000, - interval: "year", - }, - ]; - for (const row of rows) { - this.seedProduct({ - provider: this.provider, - currency: "USD", - ...row, - }); - } - } - - signWebhook( - body: string, - eventId = `evt_${this.nextId++}`, - occurredAt = new Date(), - ) { - const timestamp = String(Math.floor(occurredAt.getTime() / 1000)); - return { - body, - headers: { - "webhook-id": eventId, - "webhook-timestamp": timestamp, - "webhook-signature": `v1,${hmac(this.webhookKey, `${eventId}.${timestamp}.${body}`)}`, - }, - }; - } - - async simulatePayment( - sessionId: string, - occurredAt = new Date(), - ): Promise { - const checkout = this.checkoutsById.get(sessionId); - if (!checkout) - throw new BillingProviderError("invalid", "checkout_not_found"); - const existing = [...this.subscriptions.values()].find( - (row) => - row.metadata.sendlitCheckoutAttemptId === checkout.attemptId, - ); - if (existing) return existing; - const periodEnd = new Date( - occurredAt.getTime() + 30 * 24 * 60 * 60 * 1000, - ); - const trialEndsAt = - checkout.trialDays > 0 - ? new Date( - occurredAt.getTime() + - checkout.trialDays * 24 * 60 * 60 * 1000, - ) - : null; - const snapshot: SubscriptionSnapshot = { - provider: this.provider, - providerCustomerId: checkout.customerId, - providerSubscriptionId: `sub_${this.nextId++}`, - providerProductId: checkout.productId, - status: - trialEndsAt && trialEndsAt > occurredAt ? "trialing" : "active", - currentPeriodStartsAt: occurredAt, - currentPeriodEndsAt: periodEnd, - paidThroughAt: periodEnd, - trialEndsAt, - cancelAtPeriodEnd: false, - occurredAt, - metadata: { - sendlitCheckoutAttemptId: checkout.attemptId, - catalogKey: checkout.catalogKey, - }, - }; - this.subscriptions.set(snapshot.providerSubscriptionId, snapshot); - return snapshot; - } - - private failIfInjected(): void { - if (!this.nextFailure) return; - const error = this.nextFailure; - this.nextFailure = null; - throw error; - } - - async createCustomer(input: { - email: string; - name?: string | null; - idempotencyKey: string; - }): Promise { - this.failIfInjected(); - const existing = this.customersByKey.get(input.idempotencyKey); - if (existing) return existing; - const customer: BillingCustomer = { - provider: this.provider, - providerCustomerId: `cus_${this.nextId++}`, - }; - this.customersByKey.set(input.idempotencyKey, customer); - this.customersById.set(customer.providerCustomerId, customer); - return customer; - } - - async createCheckout(input: { - productId: string; - currency: string; - customerId: string; - payerEmail: string; - returnUrl: string; - cancelUrl?: string; - attemptId: string; - catalogKey: string; - trialDays: number; - idempotencyKey: string; - }): Promise { - this.failIfInjected(); - if (!this.customersById.has(input.customerId)) { - throw new BillingProviderError("invalid", "customer_not_found"); - } - if (!this.products.has(input.productId)) { - throw new BillingProviderError("invalid", "product_not_found"); - } - const existing = this.checkoutsByKey.get(input.idempotencyKey); - if (existing) { - return { - provider: existing.provider, - providerCheckoutSessionId: existing.providerCheckoutSessionId, - checkoutUrl: existing.checkoutUrl, - }; - } - const sessionId = `cs_${this.nextId++}`; - const stored: StoredCheckout = { - provider: this.provider, - providerCheckoutSessionId: sessionId, - checkoutUrl: `https://billing.test/checkout/${sessionId}`, - productId: input.productId, - customerId: input.customerId, - attemptId: input.attemptId, - catalogKey: input.catalogKey, - trialDays: input.trialDays, - idempotencyKey: input.idempotencyKey, - }; - this.checkoutsByKey.set(input.idempotencyKey, stored); - this.checkoutsById.set(sessionId, stored); - return { - provider: stored.provider, - providerCheckoutSessionId: stored.providerCheckoutSessionId, - checkoutUrl: stored.checkoutUrl, - }; - } - - async createPortalSession(input: { - customerId: string; - returnUrl: string; - }): Promise { - this.failIfInjected(); - if (!this.customersById.has(input.customerId)) { - throw new BillingProviderError("invalid", "customer_not_found"); - } - return { - provider: this.provider, - portalUrl: `https://billing.test/portal/${input.customerId}?return=${encodeURIComponent(input.returnUrl)}`, - }; - } - - async changeSubscriptionPlan( - input: SubscriptionPlanChangeInput, - ): Promise { - this.failIfInjected(); - const current = this.subscriptions.get(input.providerSubscriptionId); - if (!current) - throw new BillingProviderError("invalid", "subscription_not_found"); - if (!this.products.has(input.targetProviderProductId)) { - throw new BillingProviderError("invalid", "product_not_found"); - } - if (input.effectiveAt === "immediately") { - this.subscriptions.set(input.providerSubscriptionId, { - ...current, - providerProductId: input.targetProviderProductId, - occurredAt: new Date(), - }); - } - return { - provider: this.provider, - providerPaymentId: - input.prorationMode === "prorated_immediately" - ? `pay_${this.nextId++}` - : null, - paymentUrl: null, - }; - } - - async retrieveProduct(id: string): Promise { - this.failIfInjected(); - const product = this.products.get(id); - if (!product) - throw new BillingProviderError("invalid", "product_not_found"); - return { ...product }; - } - - async retrieveSubscription(id: string): Promise { - this.failIfInjected(); - const subscription = this.subscriptions.get(id); - if (!subscription) { - throw new BillingProviderError("invalid", "subscription_not_found"); - } - return { ...subscription, occurredAt: new Date() }; - } - - async cancelSubscription( - id: string, - _idempotencyKey: string, - ): Promise { - this.failIfInjected(); - const current = this.subscriptions.get(id); - if (!current) - throw new BillingProviderError("invalid", "subscription_not_found"); - this.subscriptions.set(id, { - ...current, - status: "cancelled", - cancelAtPeriodEnd: false, - occurredAt: new Date(), - }); - } - - async parseWebhook( - input: RawWebhookRequest, - ): Promise { - const eventId = - input.headers["webhook-id"] ?? input.headers["Webhook-Id"]; - const timestamp = - input.headers["webhook-timestamp"] ?? - input.headers["Webhook-Timestamp"]; - const signature = - input.headers["webhook-signature"] ?? - input.headers["Webhook-Signature"]; - if (!eventId || !timestamp || !signature) { - throw new Error("webhook_signature_invalid"); - } - const age = Math.abs(Date.now() / 1000 - Number(timestamp)); - if (!Number.isFinite(age) || age > WEBHOOK_MAX_AGE_SECONDS) { - throw new Error("webhook_timestamp_stale"); - } - const expected = `v1,${hmac(this.webhookKey, `${eventId}.${timestamp}.${input.body}`)}`; - if (!equalHex(signature, expected)) { - throw new Error("webhook_signature_invalid"); - } - const event = JSON.parse(input.body) as { - type?: string; - data?: { - subscription_id?: string; - customer_id?: string; - product_id?: string; - status?: string; - metadata?: { - sendlitCheckoutAttemptId?: string; - catalogKey?: string; - }; - }; - }; - const occurredAt = new Date(Number(timestamp) * 1000); - const subscriptionId = event.data?.subscription_id; - const stored = subscriptionId - ? this.subscriptions.get(subscriptionId) - : undefined; - const snapshot = stored - ? { ...stored, occurredAt } - : event.data?.subscription_id - ? { - provider: this.provider, - providerCustomerId: event.data.customer_id ?? "", - providerSubscriptionId: event.data.subscription_id, - providerProductId: event.data.product_id ?? "", - status: - (event.data.status as SubscriptionSnapshot["status"]) ?? - "pending", - currentPeriodStartsAt: null, - currentPeriodEndsAt: null, - paidThroughAt: null, - trialEndsAt: null, - cancelAtPeriodEnd: false, - occurredAt, - metadata: { - sendlitCheckoutAttemptId: - event.data.metadata?.sendlitCheckoutAttemptId, - catalogKey: event.data.metadata?.catalogKey, - }, - } - : undefined; - return { - provider: this.provider, - providerEventId: eventId, - eventType: String(event.type ?? "unknown"), - occurredAt, - subscriptionId, - snapshot, - rawPayload: event, - }; - } -} +export { + FakeBillingProvider, + FAKE_BILLING_WEBHOOK_KEY, +} from "@codelitdev/billing/providers"; diff --git a/apps/api/src/billing/reconciliation.ts b/apps/api/src/billing/reconciliation.ts index 3f2ca7c..f748c26 100644 --- a/apps/api/src/billing/reconciliation.ts +++ b/apps/api/src/billing/reconciliation.ts @@ -8,38 +8,29 @@ import { lt, or, inArray, - sql, } from "drizzle-orm"; import { db } from "../db/client"; import { billingCheckoutAttempts, billingPlanChangeAttempts, billingPriceEntries, - billingWebhookEvents, - organizationSubscriptions, + billingSubscriptions, organizations, planSendReservations, sendingDomains, } from "../db/schema"; import { readBillingConfig } from "./catalog"; -import { getBillingProvider } from "./provider-registry"; import { recordRequestedCatalogRevision, verifyCatalogAgainstProvider, } from "./catalog-store"; import { recordBillingMetric } from "./metrics"; import { providerErrorSummary } from "./provider"; -import { - applyCanonicalBillingEvent, - claimBillingWebhookEvent, - expireCancelledSubscriptionEntitlements, - processBillingWebhookInboxEvent, -} from "./webhooks/processor"; +import { expireCancelledSubscriptionEntitlements } from "./webhooks/processor"; import { resumeOrganizationCheckoutAttempt } from "./checkout"; import { settleExpiredSendReservation } from "./entitlements"; import { evaluateAllTeamReputations } from "./reputation"; import { verifySendingDomain } from "./domains"; -import { encryptBillingValue } from "./crypto"; import { evaluateBillingSloAlerts, recordBillingHourlySuccess } from "./alerts"; import logger from "../services/log"; @@ -49,36 +40,12 @@ let hourlyRunning = false; let inboxRunning = false; export async function processBillingInboxOnce(now = new Date()): Promise { - const inbox = await db - .select({ id: billingWebhookEvents.id }) - .from(billingWebhookEvents) - .where( - or( - eq(billingWebhookEvents.status, "pending"), - and( - eq(billingWebhookEvents.status, "failed"), - lte(billingWebhookEvents.availableAt, now), - ), - and( - eq(billingWebhookEvents.status, "processing"), - lt(billingWebhookEvents.leaseExpiresAt, now), - ), - ), - ) - .limit(100); - for (const event of inbox) { - if (await claimBillingWebhookEvent(event.id, now)) { - await processBillingWebhookInboxEvent(event.id).catch((error) => { - logger.error( - { - billing_webhook_event_id: event.id, - error: providerErrorSummary(error), - }, - "billing webhook inbox event failed", - ); - }); - } - } + void now; + const { getBillingEngine } = await import("./engine.js"); + await getBillingEngine().runWebhookInboxBatch({ + workerId: `inbox-${process.pid}`, + limit: 100, + }); } export async function settleExpiredSendReservationsOnce( @@ -118,12 +85,11 @@ export async function reconcileBillingOnce(now = new Date()): Promise { return; } if (config.deploymentMode !== "cloud") return; + const { getBillingEngine } = await import("./engine.js"); + const billing = getBillingEngine(); try { await recordRequestedCatalogRevision(config); - const provider = getBillingProvider( - config.checkoutProvider ?? undefined, - ); - await verifyCatalogAgainstProvider(config, provider); + await verifyCatalogAgainstProvider(config); } catch (error) { logger.error( { error: providerErrorSummary(error) }, @@ -132,25 +98,11 @@ export async function reconcileBillingOnce(now = new Date()): Promise { recordBillingMetric("billing.catalog.verify_failed", {}); } const cutoff = new Date(now.getTime() - 60 * 60 * 1000); - const attempts = await db - .update(billingCheckoutAttempts) - .set({ - status: "expired", - completedAt: now, - checkoutUrlEncrypted: null, - updatedAt: now, - }) - .where( - and( - inArray(billingCheckoutAttempts.status, ["creating", "open"]), - lt(billingCheckoutAttempts.expiresAt, now), - ), - ) - .returning({ id: billingCheckoutAttempts.id }); - if (attempts.length) + const expired = await billing.runDeadlineBatch({ limit: 100 }); + if (expired) logger.info( - { billing_checkout_expired: attempts.length }, - "billing checkout attempts expired", + { billing_deadline_processed: expired }, + "billing deadline batch applied", ); const creatingAttempts = await db .select({ id: billingCheckoutAttempts.id }) @@ -223,7 +175,10 @@ export async function reconcileBillingOnce(now = new Date()): Promise { .from(billingCheckoutAttempts) .where( and( - eq(billingCheckoutAttempts.organizationId, organization.id), + eq( + billingCheckoutAttempts.billableEntityId, + organization.id, + ), inArray(billingCheckoutAttempts.status, [ "creating", "open", @@ -295,10 +250,10 @@ export async function reconcileBillingOnce(now = new Date()): Promise { await processBillingInboxOnce(now); const subscriptions = await db .select() - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - inArray(organizationSubscriptions.status, [ + inArray(billingSubscriptions.status, [ "pending", "trialing", "active", @@ -306,49 +261,17 @@ export async function reconcileBillingOnce(now = new Date()): Promise { "cancelled", ]), or( - isNull(organizationSubscriptions.lastReconciledAt), - lt(organizationSubscriptions.lastReconciledAt, cutoff), + isNull(billingSubscriptions.lastReconciledAt), + lt(billingSubscriptions.lastReconciledAt, cutoff), ), ), ) .limit(100); - for (const candidate of subscriptions) { - const [subscription] = await db - .select() - .from(organizationSubscriptions) - .where(eq(organizationSubscriptions.id, candidate.id)) - .limit(1) - .for("update", { skipLocked: true }); - if (!subscription) continue; - try { - const provider = getBillingProvider(subscription.provider); - const snapshot = await provider.retrieveSubscription( - subscription.providerSubscriptionId, - ); - await applyCanonicalBillingEvent({ - provider: subscription.provider, - providerEventId: `reconcile:${subscription.id}:${snapshot.occurredAt.toISOString()}`, - eventType: "subscription.reconciled", - occurredAt: snapshot.occurredAt, - subscriptionId: snapshot.providerSubscriptionId, - snapshot, - rawPayload: null, - }); - await db - .update(organizationSubscriptions) - .set({ lastReconciledAt: now, updatedAt: now }) - .where(eq(organizationSubscriptions.id, subscription.id)); - } catch (error) { - // Provider outages and quarantined records are isolated; the next - // hourly pass retries them without affecting other organizations. - logger.warn( - { - billing_reconciliation_subscription: subscription.id, - error: providerErrorSummary(error), - }, - "billing subscription reconciliation failed", - ); - } + for (const subscription of subscriptions) { + await billing.enqueueJob({ + provider: subscription.provider, + subscriptionId: subscription.id, + }); } // Retry ambiguous plan-change mutations with the same provider // idempotency key. A provider that already applied the request returns the @@ -357,14 +280,14 @@ export async function reconcileBillingOnce(now = new Date()): Promise { const planChanges = await db .select({ attempt: billingPlanChangeAttempts, - subscription: organizationSubscriptions, + subscription: billingSubscriptions, price: billingPriceEntries, }) .from(billingPlanChangeAttempts) .innerJoin( - organizationSubscriptions, + billingSubscriptions, eq( - organizationSubscriptions.id, + billingSubscriptions.id, billingPlanChangeAttempts.subscriptionId, ), ) @@ -391,80 +314,20 @@ export async function reconcileBillingOnce(now = new Date()): Promise { ), ) .limit(100); - for (const { attempt, subscription, price } of planChanges) { - const [claimed] = await db - .update(billingPlanChangeAttempts) - .set({ updatedAt: now }) - .where( - and( - eq(billingPlanChangeAttempts.id, attempt.id), - or( - eq(billingPlanChangeAttempts.status, "creating"), - and( - eq(billingPlanChangeAttempts.status, "pending"), - isNotNull(billingPlanChangeAttempts.lastError), - ), - ), - or( - isNull(billingPlanChangeAttempts.updatedAt), - lt(billingPlanChangeAttempts.updatedAt, cutoff), - ), - ), - ) - .returning({ id: billingPlanChangeAttempts.id }); - if (!claimed) continue; - try { - const provider = getBillingProvider(attempt.provider); - const result = await provider.changeSubscriptionPlan({ - providerSubscriptionId: subscription.providerSubscriptionId, - targetProviderProductId: price.providerProductId, - effectiveAt: attempt.effectiveAt as - "immediately" | "next_billing_date", - prorationMode: attempt.prorationMode as - "prorated_immediately" | "do_not_bill", - idempotencyKey: attempt.idempotencyKey, - }); - await db - .update(billingPlanChangeAttempts) - .set({ - providerPaymentId: result.providerPaymentId, - paymentUrlEncrypted: result.paymentUrl - ? encryptBillingValue(result.paymentUrl) - : attempt.paymentUrlEncrypted, - lastError: null, - updatedAt: now, - }) - .where(eq(billingPlanChangeAttempts.id, attempt.id)); - } catch (error) { - await db - .update(billingPlanChangeAttempts) - .set({ - lastError: providerErrorSummary(error), - updatedAt: now, - }) - .where(eq(billingPlanChangeAttempts.id, attempt.id)); - } + for (const { attempt } of planChanges) { + await billing.enqueueJob({ + provider: attempt.provider, + planChangeAttemptId: attempt.id, + }); } - // Webhook bodies contain provider metadata and are retained only for the - // documented replay window. The durable event status and subscription - // projection remain available for audit after the encrypted payload is - // purged. - await db - .update(billingWebhookEvents) - .set({ payloadEncrypted: null }) - .where( - and( - inArray(billingWebhookEvents.status, [ - "processed", - "ignored", - "quarantined", - ]), - lt( - sql`coalesce(${billingWebhookEvents.processedAt}, ${billingWebhookEvents.receivedAt})`, - new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), - ), - ), - ); + await billing.runReconciliationBatch({ + workerId: `reconcile-${process.pid}`, + limit: 200, + }); + await billing.purgeExpiredSensitiveValues({ + before: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + limit: 500, + }); await evaluateAllTeamReputations(now); recordBillingHourlySuccess(now); await evaluateBillingSloAlerts(now).catch((error) => { diff --git a/apps/api/src/billing/reputation.ts b/apps/api/src/billing/reputation.ts index 696cf9d..42d0d3a 100644 --- a/apps/api/src/billing/reputation.ts +++ b/apps/api/src/billing/reputation.ts @@ -2,7 +2,7 @@ import { and, count, eq, gt, gte, lt, sql } from "drizzle-orm"; import { db } from "../db/client"; import logger from "../services/log"; import { - organizationPlanStates, + billingPlanStates, organizationAuditEvents, outboundMessages, planSendReservations, @@ -219,8 +219,8 @@ export async function evaluateAllTeamReputations( .select({ id: teams.id }) .from(teams) .innerJoin( - organizationPlanStates, - eq(organizationPlanStates.organizationId, teams.organizationId), + billingPlanStates, + eq(billingPlanStates.billableEntityId, teams.organizationId), ) .where(sql`${teams.status} IN ('active', 'sending_suspended')`); for (const row of rows) { diff --git a/apps/api/src/billing/routes.ts b/apps/api/src/billing/routes.ts index 678d328..ec3fb05 100644 --- a/apps/api/src/billing/routes.ts +++ b/apps/api/src/billing/routes.ts @@ -7,8 +7,8 @@ import { requireAuth } from "../auth/middleware"; import { db } from "../db/client"; import { billingPlanChangeAttempts, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, } from "../db/schema"; import { readBillingConfig, type BillingOffer } from "./catalog"; import { checkoutIsAvailable, getActiveCatalog } from "./catalog-store"; @@ -34,9 +34,11 @@ import { billingMutationOrigin, ensureCsrfCookie, issueBillingActionToken, + readBillingActionToken, requireBillingAction, type BillingAction, } from "./security"; +import type { BillingActionGrant } from "@codelitdev/billing/workflows"; const router = Router(); const s = initServer(); @@ -108,7 +110,7 @@ const impl = s.router(contract.billing, { if (boundary) return boundary as any; try { const result = await createPaidOrganizationCheckout({ - payerUserId: req.userId, + payerId: req.userId, organizationName: body.organizationName, teamName: body.teamName, plan: body.plan, @@ -138,8 +140,8 @@ const impl = s.router(contract.billing, { activeRevision = cached.revision; } else { const active = await getActiveCatalog(config); - offers = active.items.map(({ catalogKey, price }) => ({ - catalogKey: catalogKey as BillingOffer["catalogKey"], + offers = active.items.map(({ offerKey, price }) => ({ + catalogKey: offerKey as BillingOffer["catalogKey"], catalogRevision: active.revision.revision, plan: price.plan as "pro" | "business", interval: price.billingInterval as "month" | "year", @@ -149,7 +151,7 @@ const impl = s.router(contract.billing, { providerProductId: price.providerProductId, trialDays: config.offers.find( - (offer) => offer.catalogKey === catalogKey, + (offer) => offer.catalogKey === offerKey, )?.trialDays ?? 0, })); activeRevision = active.revision.revision; @@ -210,10 +212,10 @@ const impl = s.router(contract.billing, { } const [planState] = await db .select() - .from(organizationPlanStates) + .from(billingPlanStates) .where( eq( - organizationPlanStates.organizationId, + billingPlanStates.billableEntityId, authorization.organization.id, ), ) @@ -221,10 +223,10 @@ const impl = s.router(contract.billing, { const [state] = planState?.activeSubscriptionId ? await db .select() - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( eq( - organizationSubscriptions.id, + billingSubscriptions.id, planState.activeSubscriptionId, ), ) @@ -241,7 +243,7 @@ const impl = s.router(contract.billing, { .where( and( eq( - billingPlanChangeAttempts.organizationId, + billingPlanChangeAttempts.billableEntityId, authorization.organization.id, ), eq(billingPlanChangeAttempts.status, "pending"), @@ -252,7 +254,7 @@ const impl = s.router(contract.billing, { authorization.organization.id, ); const usage = await usageForOrganization(authorization.organization.id); - const billingManager = state?.billingManagerUserId ?? null; + const billingManager = state?.payerId ?? null; return { status: 200, body: { @@ -333,20 +335,31 @@ const impl = s.router(contract.billing, { return { status: 404, body: { error: "organization_not_found" } }; if (authorization.membership.role !== "owner") return { status: 403, body: { error: "billing_owner_required" } }; - const boundary = await requireBillingAction( + const ready = await readBillingActionToken( req, req.res, - "checkout", params.organizationId, ); - if (boundary) return boundary as any; + if ("status" in ready) return ready as any; + const grant: BillingActionGrant = { + grantId: ready.token, + actorId: (req as any).userId, + action: "checkout", + target: { + kind: "organization", + id: authorization.organization.id, + }, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }; try { const result = await createOrganizationCheckout({ organizationId: authorization.organization.id, - payerUserId: (req as any).userId, + payerId: (req as any).userId, plan: body.plan, interval: body.interval, catalogRevision: body.catalogRevision, + grant, }); return { status: 201, body: result }; } catch (error) { @@ -364,17 +377,28 @@ const impl = s.router(contract.billing, { return { status: 404, body: { error: "organization_not_found" } }; if (authorization.membership.role !== "owner") return { status: 403, body: { error: "billing_owner_required" } }; - const boundary = await requireBillingAction( + const ready = await readBillingActionToken( req, req.res, - "portal", params.organizationId, ); - if (boundary) return boundary as any; + if ("status" in ready) return ready as any; + const grant: BillingActionGrant = { + grantId: ready.token, + actorId: (req as any).userId, + action: "portal", + target: { + kind: "organization", + id: authorization.organization.id, + }, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }; try { const result = await createOrganizationPortal({ organizationId: authorization.organization.id, userId: (req as any).userId, + grant, }); return { status: 201, body: result }; } catch (error) { @@ -398,21 +422,32 @@ const impl = s.router(contract.billing, { ); if (!authorization) return { status: 404, body: { error: "organization_not_found" } }; - const boundary = await requireBillingAction( + const ready = await readBillingActionToken( req, req.res, - "plan_change", params.organizationId, ); - if (boundary) return boundary as any; + if ("status" in ready) return ready as any; + const grant: BillingActionGrant = { + grantId: ready.token, + actorId: (req as any).userId, + action: "plan_change", + target: { + kind: "organization", + id: authorization.organization.id, + }, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }; try { const result = await createOrganizationPlanChange({ organizationId: authorization.organization.id, - actorUserId: (req as any).userId, + actorId: (req as any).userId, plan: body.plan, interval: body.interval, catalogRevision: body.catalogRevision, idempotencyKey: body.idempotencyKey, + grant, }); return { status: result.status === "pending" ? 202 : 200, diff --git a/apps/api/src/billing/security.test.ts b/apps/api/src/billing/security.test.ts index ec8b5cb..b21e638 100644 --- a/apps/api/src/billing/security.test.ts +++ b/apps/api/src/billing/security.test.ts @@ -12,6 +12,7 @@ vi.mock("../db/client", async () => { }); import { db } from "../db/client"; +import { organizations } from "../db/schema"; import { truncateAll, type TestDb } from "../test/db"; import { issueBillingActionToken, requireBillingAction } from "./security"; @@ -98,4 +99,67 @@ describe("billing action authorization", () => { body: { error: "recent_authentication_required" }, }); }); + + it("lets resume checkout finish a pending organization without recent authentication", async () => { + await tdb.insert(organizations).values({ + organizationId: "org_pending", + name: "Pending org", + status: "pending_payment", + }); + authMocks.getSession.mockResolvedValue({ + user: { id: "user-1" }, + session: { + id: "session-1", + createdAt: new Date(Date.now() - 60 * 60 * 1000), + updatedAt: new Date(), + }, + }); + + const issued = await issueBillingActionToken( + request(), + response, + "checkout", + "org_pending", + ); + expect("token" in issued).toBe(true); + }); + + it("still requires recent authentication to start checkout on an active organization", async () => { + await tdb.insert(organizations).values({ + organizationId: "org_active", + name: "Active org", + status: "active", + }); + authMocks.getSession.mockResolvedValue({ + user: { id: "user-1" }, + session: { + id: "session-1", + createdAt: new Date(Date.now() - 60 * 60 * 1000), + updatedAt: new Date(), + }, + }); + + await expect( + issueBillingActionToken( + request(), + response, + "checkout", + "org_active", + ), + ).resolves.toEqual({ + status: 401, + body: { error: "recent_authentication_required" }, + }); + await expect( + issueBillingActionToken( + request(), + response, + "organization_checkout", + "new", + ), + ).resolves.toEqual({ + status: 401, + body: { error: "recent_authentication_required" }, + }); + }); }); diff --git a/apps/api/src/billing/security.ts b/apps/api/src/billing/security.ts index d723ee2..f8742b4 100644 --- a/apps/api/src/billing/security.ts +++ b/apps/api/src/billing/security.ts @@ -8,7 +8,7 @@ import { and, eq, gt, like, lte } from "drizzle-orm"; import { fromNodeHeaders } from "better-auth/node"; import { auth } from "../auth/better-auth"; import { db } from "../db/client"; -import { verification } from "../db/schema"; +import { organizations, verification } from "../db/schema"; export type BillingAction = | "organization_checkout" @@ -121,6 +121,22 @@ async function sessionContext(req: any) { return current; } +/** Resume checkout continues a pending organization the user already created. + * A long-lived session is enough to finish that attempt; new paid checkouts + * still require authentication within BILLING_RECENT_AUTH_MAX_AGE_SECONDS. */ +async function isPendingPaymentCheckout( + action: BillingAction, + target: string, +): Promise { + if (action !== "checkout") return false; + const [organization] = await db + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.organizationId, target)) + .limit(1); + return organization?.status === "pending_payment"; +} + function commonBoundary(req: any, res: any): BillingSecurityFailure | null { if (!req.userId || req.authKind !== "session") { return { @@ -170,10 +186,12 @@ export async function issueBillingActionToken( !Number.isFinite(authenticatedAt) || Date.now() - authenticatedAt > maxAgeSeconds * 1000 ) { - return { - status: 401, - body: { error: "recent_authentication_required" }, - }; + if (!(await isPendingPaymentCheckout(action, target))) { + return { + status: 401, + body: { error: "recent_authentication_required" }, + }; + } } const token = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); @@ -207,6 +225,33 @@ export async function issueBillingActionToken( } } +export async function readBillingActionToken( + req: any, + res: any, + target: string, +): Promise { + const boundary = commonBoundary(req, res); + if (boundary) return boundary; + const token = + typeof req.headers?.["x-sendlit-billing-action-token"] === "string" + ? req.headers["x-sendlit-billing-action-token"] + : ""; + if (!token || !validTarget(target)) { + return { + status: 401, + body: { error: "billing_action_token_required" }, + }; + } + const current = await sessionContext(req); + if (!current) { + return { + status: 401, + body: { error: "recent_authentication_required" }, + }; + } + return { token }; +} + export async function requireBillingAction( req: any, res: any, diff --git a/apps/api/src/billing/webhook-retry.ts b/apps/api/src/billing/webhook-retry.ts index 6947ac2..d342b93 100644 --- a/apps/api/src/billing/webhook-retry.ts +++ b/apps/api/src/billing/webhook-retry.ts @@ -1,36 +1,4 @@ -import { randomInt } from "node:crypto"; - -/** Total claim/process attempts before a durable webhook is quarantined. */ -export const BILLING_WEBHOOK_MAX_ATTEMPTS = 8; - -const INITIAL_RETRY_DELAYS_MS = [ - 60 * 1000, - 5 * 60 * 1000, - 30 * 60 * 1000, - 2 * 60 * 60 * 1000, -] as const; - -const TAIL_RETRY_DELAY_MS = 8 * 60 * 60 * 1000; -const TAIL_RETRY_JITTER_MS = Math.floor(TAIL_RETRY_DELAY_MS * 0.1); - -export type BillingWebhookRetry = - { status: "quarantined" } | { status: "failed"; delayMs: number }; - -/** Retry schedule from the billing PRD: 1m, 5m, 30m, 2h, then 8h with jitter, - * up to eight attempts. `processingAttempts` is the count after the claim - * increment for the attempt that just failed. */ -export function billingWebhookRetry( - processingAttempts: number, - jitterMs?: number, -): BillingWebhookRetry { - if (processingAttempts >= BILLING_WEBHOOK_MAX_ATTEMPTS) { - return { status: "quarantined" }; - } - const index = Math.max(0, processingAttempts - 1); - if (index < INITIAL_RETRY_DELAYS_MS.length) { - return { status: "failed", delayMs: INITIAL_RETRY_DELAYS_MS[index] }; - } - const jitter = - jitterMs ?? randomInt(-TAIL_RETRY_JITTER_MS, TAIL_RETRY_JITTER_MS + 1); - return { status: "failed", delayMs: TAIL_RETRY_DELAY_MS + jitter }; -} +export { + billingWebhookRetry, + DEFAULT_WEBHOOK_MAX_ATTEMPTS as BILLING_WEBHOOK_MAX_ATTEMPTS, +} from "@codelitdev/billing/core"; diff --git a/apps/api/src/billing/webhooks/processor.ts b/apps/api/src/billing/webhooks/processor.ts index 00eea8e..78ae2e9 100644 --- a/apps/api/src/billing/webhooks/processor.ts +++ b/apps/api/src/billing/webhooks/processor.ts @@ -8,14 +8,19 @@ import { billingTrialClaims, billingWebhookEvents, organizationAuditEvents, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, organizations, settings, teamDeliverySettings, teamMembers, teams, } from "../../db/schema"; +import { + decideSubscriptionTransition, + retainsPaidEntitlement as packageRetainsPaidEntitlement, + type CanonicalSubscriptionStatus, +} from "@codelitdev/billing/core"; import { providerErrorSummary, type CanonicalBillingEvent } from "../provider"; import { decryptBillingValue } from "../crypto"; import { getBillingProvider } from "../provider-registry"; @@ -25,34 +30,13 @@ import logger from "../../services/log"; import { notifyPaymentPastDue } from "../notifications"; import { pageBillingAlert } from "../alerts"; -const paidStatuses = new Set(["active", "trialing", "past_due"]); - -const allowedTransitions: Record> = { - pending: new Set(["pending", "trialing", "active", "cancelled", "expired"]), - trialing: new Set([ - "trialing", - "active", - "past_due", - "cancelled", - "expired", - ]), - active: new Set(["active", "past_due", "cancelled", "expired"]), - past_due: new Set(["past_due", "active", "cancelled", "expired"]), - // A cancellation can be reversed before the paid-through deadline. Dodo - // reports that as active (or trialing/past_due), so recovery must be a - // valid transition rather than being quarantined as stale state. - cancelled: new Set([ - "cancelled", - "trialing", - "active", - "past_due", - "expired", - ]), - expired: new Set(["expired"]), -}; - -function transitionAllowed(previous: string, next: string): boolean { - return allowedTransitions[previous]?.has(next) ?? false; +function checkoutAttemptIdFromSnapshot(snapshot: { + metadata: Record; +}): string | undefined { + return ( + snapshot.metadata.checkoutAttemptId ?? + snapshot.metadata.sendlitCheckoutAttemptId + ); } /** Apply a provider snapshot atomically. Events are merely wake-up signals; @@ -84,12 +68,12 @@ export async function applyCanonicalBillingEvent( const [existing] = await tx .select() - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - eq(organizationSubscriptions.provider, event.provider), + eq(billingSubscriptions.provider, event.provider), eq( - organizationSubscriptions.providerSubscriptionId, + billingSubscriptions.providerSubscriptionId, snapshot.providerSubscriptionId, ), ), @@ -98,26 +82,21 @@ export async function applyCanonicalBillingEvent( .for("update"); let attempt = null as typeof billingCheckoutAttempts.$inferSelect | null; - if (!existing && snapshot.metadata.sendlitCheckoutAttemptId) { + const checkoutAttemptId = checkoutAttemptIdFromSnapshot(snapshot); + if (!existing && checkoutAttemptId) { const [row] = await tx .select() .from(billingCheckoutAttempts) - .where( - eq( - billingCheckoutAttempts.attemptId, - snapshot.metadata.sendlitCheckoutAttemptId, - ), - ) + .where(eq(billingCheckoutAttempts.attemptId, checkoutAttemptId)) .limit(1) .for("update"); attempt = row ?? null; } const organizationId = - existing?.organizationId ?? attempt?.organizationId; + existing?.billableEntityId ?? attempt?.billableEntityId; const billingCustomerId = existing?.billingCustomerId ?? attempt?.billingCustomerId; - const billingManagerUserId = - existing?.billingManagerUserId ?? attempt?.payerUserId; + const billingManagerUserId = existing?.payerId ?? attempt?.payerId; if (!organizationId || !billingCustomerId || !billingManagerUserId) { throw new Error("billing_subscription_unmatched"); } @@ -164,33 +143,37 @@ export async function applyCanonicalBillingEvent( } const [state] = await tx .select() - .from(organizationPlanStates) - .where(eq(organizationPlanStates.organizationId, organizationId)) + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) .limit(1) .for("update"); if (!state) throw new Error("organization_plan_state_missing"); // Ordering follows the authoritative subscription snapshot. Dodo // webhook timestamps can be delayed, while retrieveSubscription gives // the current state and timestamp used for this projection. - const providerOccurredAt = snapshot.occurredAt; + const providerOccurredAt = + snapshot.providerOccurredAt ?? snapshot.observedAt; if ( - existing?.lastProviderEventAt && - existing.lastProviderEventAt > providerOccurredAt + existing?.providerOccurredAt && + snapshot.providerOccurredAt && + existing.providerOccurredAt > snapshot.providerOccurredAt ) return; - if (existing && !transitionAllowed(existing.status, snapshot.status)) { - throw new Error("billing_invalid_subscription_transition"); + if (existing) { + const decision = decideSubscriptionTransition( + existing.status as CanonicalSubscriptionStatus, + snapshot.status, + ); + if (!decision.allowed) { + throw new Error("billing_invalid_subscription_transition"); + } } - const catalogKey = price.catalogKey; + const catalogKey = price.offerKey; const now = new Date(); - const retainsPaidEntitlement = - paidStatuses.has(snapshot.status) || - (snapshot.status === "cancelled" && - snapshot.cancelAtPeriodEnd && - Boolean( - snapshot.paidThroughAt && - snapshot.paidThroughAt.getTime() > now.getTime(), - )); + const retainsPaidEntitlement = packageRetainsPaidEntitlement( + snapshot, + now, + ); const graceEndsAt = snapshot.status === "past_due" ? existing?.status === "past_due" && existing.graceEndsAt @@ -223,14 +206,16 @@ export async function applyCanonicalBillingEvent( (retainsPaidEntitlement || state.activeSubscriptionId === existing?.id); const values = { - organizationId, + billableEntityId: organizationId, billingCustomerId, - billingManagerUserId, + payerId: billingManagerUserId, provider: event.provider, providerSubscriptionId: snapshot.providerSubscriptionId, providerProductId: snapshot.providerProductId, billingPriceEntryId: price.id, - catalogKey, + catalogRevision: + existing?.catalogRevision ?? attempt?.catalogRevision ?? 0, + offerKey: catalogKey, plan: price.plan, billingInterval: price.billingInterval, status: snapshot.status, @@ -242,20 +227,17 @@ export async function applyCanonicalBillingEvent( graceEndsAt, cancelAtPeriodEnd: snapshot.cancelAtPeriodEnd, isEntitlementSource: shouldProject && retainsPaidEntitlement, - lastProviderEventAt: providerOccurredAt, + providerOccurredAt: providerOccurredAt, lastReconciledAt: new Date(), updatedAt: new Date(), } as const; const [subscription] = existing ? await tx - .update(organizationSubscriptions) + .update(billingSubscriptions) .set(values) - .where(eq(organizationSubscriptions.id, existing.id)) + .where(eq(billingSubscriptions.id, existing.id)) .returning() - : await tx - .insert(organizationSubscriptions) - .values(values) - .returning(); + : await tx.insert(billingSubscriptions).values(values).returning(); if (!subscription) throw new Error("billing_subscription_projection_failed"); // Only one subscription can grant entitlements. A newly active one @@ -263,22 +245,22 @@ export async function applyCanonicalBillingEvent( // are rejected by the database partial unique index. if (shouldProject) { await tx - .update(organizationSubscriptions) + .update(billingSubscriptions) .set({ isEntitlementSource: false, updatedAt: new Date() }) .where( and( eq( - organizationSubscriptions.organizationId, + billingSubscriptions.billableEntityId, organizationId, ), - eq(organizationSubscriptions.isEntitlementSource, true), + eq(billingSubscriptions.isEntitlementSource, true), ), ); if (retainsPaidEntitlement) { await tx - .update(organizationSubscriptions) + .update(billingSubscriptions) .set({ isEntitlementSource: true, updatedAt: new Date() }) - .where(eq(organizationSubscriptions.id, subscription.id)); + .where(eq(billingSubscriptions.id, subscription.id)); } } const active = shouldProject && retainsPaidEntitlement; @@ -286,7 +268,7 @@ export async function applyCanonicalBillingEvent( const nextSubscriptionId = active ? subscription.id : null; if (shouldProject) { await tx - .update(organizationPlanStates) + .update(billingPlanStates) .set({ plan: nextPlan, activeSubscriptionId: nextSubscriptionId, @@ -297,7 +279,7 @@ export async function applyCanonicalBillingEvent( projectionVersion: state.projectionVersion + 1, updatedAt: new Date(), }) - .where(eq(organizationPlanStates.id, state.id)); + .where(eq(billingPlanStates.id, state.id)); if ( state.plan !== nextPlan || state.activeSubscriptionId !== nextSubscriptionId @@ -386,7 +368,7 @@ export async function applyCanonicalBillingEvent( .values({ teamId: team.id }); await tx.insert(teamMembers).values({ teamId: team.id, - userId: attempt.payerUserId, + userId: attempt.payerId, role: "admin", }); } @@ -424,15 +406,15 @@ export async function expireCancelledSubscriptionEntitlements( ): Promise { const due = await db .select({ - id: organizationSubscriptions.id, - organizationId: organizationSubscriptions.organizationId, + id: billingSubscriptions.id, + organizationId: billingSubscriptions.billableEntityId, }) - .from(organizationSubscriptions) + .from(billingSubscriptions) .where( and( - eq(organizationSubscriptions.status, "cancelled"), - eq(organizationSubscriptions.isEntitlementSource, true), - lte(organizationSubscriptions.paidThroughAt, now), + eq(billingSubscriptions.status, "cancelled"), + eq(billingSubscriptions.isEntitlementSource, true), + lte(billingSubscriptions.paidThroughAt, now), ), ) .limit(500); @@ -441,8 +423,8 @@ export async function expireCancelledSubscriptionEntitlements( const applied = await db.transaction(async (tx) => { const [subscription] = await tx .select() - .from(organizationSubscriptions) - .where(eq(organizationSubscriptions.id, row.id)) + .from(billingSubscriptions) + .where(eq(billingSubscriptions.id, row.id)) .limit(1) .for("update"); if ( @@ -461,30 +443,27 @@ export async function expireCancelledSubscriptionEntitlements( .for("update"); const [state] = await tx .select() - .from(organizationPlanStates) + .from(billingPlanStates) .where( - eq( - organizationPlanStates.organizationId, - row.organizationId, - ), + eq(billingPlanStates.billableEntityId, row.organizationId), ) .limit(1) .for("update"); if (!state || state.activeSubscriptionId !== subscription.id) return false; await tx - .update(organizationSubscriptions) + .update(billingSubscriptions) .set({ isEntitlementSource: false, updatedAt: now }) - .where(eq(organizationSubscriptions.id, subscription.id)); + .where(eq(billingSubscriptions.id, subscription.id)); await tx - .update(organizationPlanStates) + .update(billingPlanStates) .set({ plan: "free", activeSubscriptionId: null, projectionVersion: state.projectionVersion + 1, updatedAt: now, }) - .where(eq(organizationPlanStates.id, state.id)); + .where(eq(billingPlanStates.id, state.id)); await tx.insert(organizationAuditEvents).values({ organizationId: row.organizationId, actorType: "system", @@ -565,54 +544,26 @@ export async function processBillingWebhookInboxEvent( providerEventId: string; eventType: string; occurredAt: string; - subscriptionId?: string; - snapshot?: Record; + subscriptionId?: string | null; + verifiedKeyVersion?: string | null; + correlationMetadata?: { + checkoutAttemptId?: string; + catalogKey?: string; + }; }; }; if (envelope.canonical) { const canonical = envelope.canonical; - const rawSnapshot = canonical.snapshot; event = { provider: canonical.provider, providerEventId: canonical.providerEventId, eventType: canonical.eventType, occurredAt: new Date(canonical.occurredAt), - subscriptionId: canonical.subscriptionId, - rawPayload: null, - snapshot: rawSnapshot - ? ({ - ...(rawSnapshot as any), - currentPeriodStartsAt: - rawSnapshot.currentPeriodStartsAt - ? new Date( - String( - rawSnapshot.currentPeriodStartsAt, - ), - ) - : null, - currentPeriodEndsAt: - rawSnapshot.currentPeriodEndsAt - ? new Date( - String( - rawSnapshot.currentPeriodEndsAt, - ), - ) - : null, - paidThroughAt: rawSnapshot.paidThroughAt - ? new Date( - String(rawSnapshot.paidThroughAt), - ) - : null, - trialEndsAt: rawSnapshot.trialEndsAt - ? new Date( - String(rawSnapshot.trialEndsAt), - ) - : null, - occurredAt: rawSnapshot.occurredAt - ? new Date(String(rawSnapshot.occurredAt)) - : new Date(canonical.occurredAt), - } as CanonicalBillingEvent["snapshot"]) - : undefined, + subscriptionId: canonical.subscriptionId ?? null, + verifiedKeyVersion: + canonical.verifiedKeyVersion ?? null, + correlationMetadata: + canonical.correlationMetadata ?? {}, }; } if (typeof envelope.body === "string") { diff --git a/apps/api/src/billing/webhooks/routes.ts b/apps/api/src/billing/webhooks/routes.ts index 7d3a3fc..e82db5e 100644 --- a/apps/api/src/billing/webhooks/routes.ts +++ b/apps/api/src/billing/webhooks/routes.ts @@ -1,41 +1,10 @@ import express, { Router } from "express"; import rateLimit from "express-rate-limit"; -import { db } from "../../db/client"; -import { billingWebhookEvents } from "../../db/schema"; -import { encryptBillingValue } from "../crypto"; -import { getBillingProvider } from "../provider-registry"; -import { - claimBillingWebhookEvent, - processBillingWebhookInboxEvent, -} from "./processor"; -import type { CanonicalBillingEvent } from "../provider"; +import { getBillingEngine } from "../engine"; import { pageBillingAlert, recordWebhookSignatureFailure } from "../alerts"; const router = Router(); -function serializeCanonicalEvent(event: CanonicalBillingEvent) { - const snapshot = event.snapshot - ? { - ...event.snapshot, - occurredAt: event.snapshot.occurredAt.toISOString(), - currentPeriodStartsAt: - event.snapshot.currentPeriodStartsAt?.toISOString() ?? null, - currentPeriodEndsAt: - event.snapshot.currentPeriodEndsAt?.toISOString() ?? null, - paidThroughAt: - event.snapshot.paidThroughAt?.toISOString() ?? null, - trialEndsAt: event.snapshot.trialEndsAt?.toISOString() ?? null, - } - : undefined; - return { - provider: event.provider, - providerEventId: event.providerEventId, - eventType: event.eventType, - occurredAt: event.occurredAt.toISOString(), - subscriptionId: event.subscriptionId, - snapshot, - }; -} const limiter = rateLimit({ windowMs: 60_000, max: 300, @@ -59,11 +28,21 @@ router.post( else if (Array.isArray(value)) headers[key.toLowerCase()] = value[0] ?? ""; } - let event; - let provider; try { - provider = getBillingProvider("dodo"); - event = await provider.parseWebhook({ body, headers }); + const billing = getBillingEngine(); + const ingested = await billing.ingestWebhook({ + provider: "dodo", + raw: { body, headers }, + }); + if (ingested.duplicate) { + return res + .status(200) + .json({ accepted: true, duplicate: true }); + } + res.status(202).json({ accepted: true }); + void billing + .runWebhookInboxBatch({ workerId: `billing-${process.pid}` }) + .catch(() => undefined); } catch { const count = recordWebhookSignatureFailure(); if (count >= 10) { @@ -75,55 +54,6 @@ router.post( } return res.status(400).json({ error: "webhook_signature_invalid" }); } - try { - const [stored] = await db - .insert(billingWebhookEvents) - .values({ - provider: event.provider, - providerEventId: event.providerEventId, - eventType: event.eventType, - occurredAt: event.occurredAt, - // Keep the verified raw headers with the encrypted body so - // a later inbox worker can re-verify a replay after the - // request process has exited. - payloadEncrypted: encryptBillingValue( - JSON.stringify({ - body, - headers, - canonical: serializeCanonicalEvent(event), - }), - ), - payloadKeyVersion: - process.env.BILLING_DATA_ENCRYPTION_KEY_VERSION || "v1", - status: "pending", - }) - .returning({ id: billingWebhookEvents.id }); - if (!stored) - return res - .status(500) - .json({ error: "webhook_persistence_failed" }); - await claimBillingWebhookEvent(stored.id); - res.status(202).json({ accepted: true }); - // Processing is durable and asynchronous; consume the terminal - // rejection so a quarantined event cannot become an unhandled - // promise rejection in the API process. - // Read the durable envelope again in the worker. Subscription - // events are then refreshed from the provider before projection; - // provider outages become inbox retries rather than failed webhook - // deliveries that exist only in the provider's retry queue. - void processBillingWebhookInboxEvent(stored.id).catch( - () => undefined, - ); - } catch (error: any) { - if (error?.code === "23505") { - return res - .status(200) - .json({ accepted: true, duplicate: true }); - } - return res - .status(500) - .json({ error: "webhook_persistence_failed" }); - } }, ); diff --git a/apps/api/src/db/billing-extensions.ts b/apps/api/src/db/billing-extensions.ts new file mode 100644 index 0000000..117c160 --- /dev/null +++ b/apps/api/src/db/billing-extensions.ts @@ -0,0 +1,48 @@ +import { check, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { pgTable } from "drizzle-orm/pg-core"; +import { genId } from "./id"; +import { organizations, user } from "./schema-core"; +import { billingCheckoutAttempts } from "./billing.generated"; + +/** SendLit trial-abuse claims. Not a canonical billing table. */ +export const billingTrialClaims = pgTable( + "billing_trial_claims", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + verifiedEmailFingerprint: text("verified_email_fingerprint").notNull(), + fingerprintKeyVersion: text("fingerprint_key_version").notNull(), + trialKey: text("trial_key").notNull(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + checkoutAttemptId: uuid("checkout_attempt_id").references( + () => billingCheckoutAttempts.id, + { onDelete: "restrict" }, + ), + status: text("status").notNull().default("reserved"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + redeemedAt: timestamp("redeemed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + userTrialUnique: uniqueIndex("billing_trial_claims_user_trial_uidx") + .on(table.userId, table.trialKey) + .where(sql`${table.status} <> 'released'`), + emailTrialUnique: uniqueIndex("billing_trial_claims_email_trial_uidx") + .on(table.verifiedEmailFingerprint, table.trialKey) + .where(sql`${table.status} <> 'released'`), + statusCheck: check( + "billing_trial_claims_status_check", + sql`${table.status} IN ('reserved', 'redeemed', 'released')`, + ), + }), +); diff --git a/apps/api/src/db/billing.generated.ts b/apps/api/src/db/billing.generated.ts new file mode 100644 index 0000000..9e359f3 --- /dev/null +++ b/apps/api/src/db/billing.generated.ts @@ -0,0 +1,529 @@ +/** + * AUTO-GENERATED FILE. DO NOT EDIT. + * Generated by @codelitdev/billing schema version 2. + */ +import { sql } from "drizzle-orm"; +import { + boolean, + check, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +// The generated artifact is also loaded directly by drizzle-kit, which resolves +// extensionless application TypeScript imports. Keep those imports portable for +// the consuming application's module resolver. +// @ts-ignore drizzle-kit resolves the extensionless TypeScript import. +import { organizations } from "./schema-core"; +// @ts-ignore drizzle-kit resolves the extensionless TypeScript import. +import { user } from "./schema-core"; + +const timestamps = { + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}; + +export const billingPriceEntries = pgTable( + "billing_price_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + offerKey: text("offer_key").notNull(), + plan: text("plan").notNull(), + billingInterval: text("billing_interval").notNull(), + currency: text("currency").notNull(), + amountMinor: integer("amount_minor").notNull(), + providerTrialDays: integer("provider_trial_days").notNull().default(0), + provider: text("provider").notNull(), + providerProductId: text("provider_product_id").notNull(), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + ...timestamps, + }, + (table) => ({ + providerProductUnique: uniqueIndex( + "billing_price_entries_provider_product_uidx", + ).on(table.provider, table.providerProductId), + offerKeyIdx: index("billing_price_entries_offer_key_idx").on( + table.offerKey, + ), + amountCheck: check( + "billing_price_entries_amount_check", + sql`${table.amountMinor} > 0`, + ), + trialDaysCheck: check( + "billing_price_entries_trial_days_check", + sql`${table.providerTrialDays} >= 0`, + ), + currencyCheck: check( + "billing_price_entries_currency_check", + sql`${table.currency} ~ '^[A-Z]{3}$'`, + ), + planCheck: check( + "billing_price_entries_plan_check", + sql`${table.plan} IN ('pro', 'business')`, + ), + intervalCheck: check( + "billing_price_entries_interval_check", + sql`${table.billingInterval} IN ('month', 'year')`, + ), + }), +); + +export const billingCatalogRevisions = pgTable( + "billing_catalog_revisions", + { + id: uuid("id").primaryKey().defaultRandom(), + revision: integer("revision").notNull().unique(), + checkoutProvider: text("checkout_provider").notNull(), + status: text("status").notNull().default("pending_verification"), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + activatedAt: timestamp("activated_at", { withTimezone: true }), + retiredAt: timestamp("retired_at", { withTimezone: true }), + ...timestamps, + }, + (table) => ({ + statusCheck: check( + "billing_catalog_revisions_status_check", + sql`${table.status} IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')`, + ), + revisionCheck: check( + "billing_catalog_revisions_revision_check", + sql`${table.revision} > 0`, + ), + activeProviderUnique: uniqueIndex( + "billing_catalog_revisions_active_provider_uidx", + ) + .on(table.checkoutProvider) + .where(sql`${table.status} = 'active'`), + }), +); + +export const billingCatalogRevisionItems = pgTable( + "billing_catalog_revision_items", + { + id: uuid("id").primaryKey().defaultRandom(), + catalogRevisionId: uuid("catalog_revision_id") + .notNull() + .references(() => billingCatalogRevisions.id, { + onDelete: "cascade", + }), + offerKey: text("offer_key").notNull(), + billingPriceEntryId: uuid("billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + }, + (table) => ({ + revisionKeyUnique: uniqueIndex( + "billing_catalog_revision_items_revision_key_uidx", + ).on(table.catalogRevisionId, table.offerKey), + revisionPriceUnique: uniqueIndex( + "billing_catalog_revision_items_revision_price_uidx", + ).on(table.catalogRevisionId, table.billingPriceEntryId), + }), +); + +export const billingProviderCustomers = pgTable( + "billing_provider_customers", + { + id: uuid("id").primaryKey().defaultRandom(), + provider: text("provider").notNull(), + payerId: text("payer_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + payerEmail: text("payer_email").notNull().default(""), + providerCustomerId: text("provider_customer_id"), + idempotencyKey: text("idempotency_key").notNull(), + status: text("status").notNull().default("creating"), + lastError: text("last_error"), + ...timestamps, + }, + (table) => ({ + providerPayerUnique: uniqueIndex( + "billing_provider_customers_provider_payer_uidx", + ).on(table.provider, table.payerId), + providerCustomerUnique: uniqueIndex( + "billing_provider_customers_provider_customer_uidx", + ) + .on(table.provider, table.providerCustomerId) + .where(sql`${table.providerCustomerId} IS NOT NULL`), + idempotencyUnique: uniqueIndex( + "billing_provider_customers_idempotency_uidx", + ).on(table.idempotencyKey), + statusCheck: check( + "billing_provider_customers_status_check", + sql`${table.status} IN ('creating', 'active', 'conflicted')`, + ), + }), +); + +export const billingCheckoutAttempts = pgTable( + "billing_checkout_attempts", + { + id: uuid("id").primaryKey().defaultRandom(), + attemptId: text("attempt_id").notNull().unique(), + billableEntityId: uuid("billable_entity_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + payerId: text("payer_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + payerEmail: text("payer_email").notNull().default(""), + returnUrl: text("return_url").notNull().default(""), + provider: text("provider").notNull(), + catalogRevision: integer("catalog_revision").notNull(), + offerKey: text("offer_key").notNull(), + requestedPlan: text("requested_plan").notNull(), + requestedInterval: text("requested_interval").notNull(), + billingPriceEntryId: uuid("billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + quotedAmountMinor: integer("quoted_amount_minor").notNull(), + quotedCurrency: text("quoted_currency").notNull(), + billingCustomerId: uuid("billing_customer_id").references( + () => billingProviderCustomers.id, + { onDelete: "restrict" }, + ), + providerCheckoutSessionId: text("provider_checkout_session_id"), + checkoutUrlEncrypted: text("checkout_url_encrypted"), + idempotencyKey: text("idempotency_key").notNull(), + status: text("status").notNull().default("creating"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + lastError: text("last_error"), + completedAt: timestamp("completed_at", { withTimezone: true }), + ...timestamps, + pendingTeamName: text("pending_team_name"), + }, + (table) => ({ + providerSessionUnique: uniqueIndex( + "billing_checkout_attempts_provider_session_uidx", + ) + .on(table.provider, table.providerCheckoutSessionId) + .where(sql`${table.providerCheckoutSessionId} IS NOT NULL`), + idempotencyUnique: uniqueIndex( + "billing_checkout_attempts_idempotency_uidx", + ).on(table.idempotencyKey), + entityNonterminalUnique: uniqueIndex( + "billing_checkout_attempts_entity_nonterminal_uidx", + ) + .on(table.billableEntityId) + .where(sql`${table.status} IN ('creating', 'open')`), + statusCheck: check( + "billing_checkout_attempts_status_check", + sql`${table.status} IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')`, + ), + amountCheck: check( + "billing_checkout_attempts_amount_check", + sql`${table.quotedAmountMinor} > 0`, + ), + planCheck: check( + "billing_checkout_attempts_plan_check", + sql`${table.requestedPlan} IN ('pro', 'business')`, + ), + intervalCheck: check( + "billing_checkout_attempts_interval_check", + sql`${table.requestedInterval} IN ('month', 'year')`, + ), + }), +); + +export const billingSubscriptions = pgTable( + "billing_subscriptions", + { + id: uuid("id").primaryKey().defaultRandom(), + billableEntityId: uuid("billable_entity_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + billingCustomerId: uuid("billing_customer_id") + .notNull() + .references(() => billingProviderCustomers.id, { + onDelete: "restrict", + }), + payerId: text("payer_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + originCheckoutAttemptId: uuid("origin_checkout_attempt_id").references( + () => billingCheckoutAttempts.id, + { onDelete: "restrict" }, + ), + provider: text("provider").notNull(), + providerSubscriptionId: text("provider_subscription_id").notNull(), + providerProductId: text("provider_product_id").notNull(), + billingPriceEntryId: uuid("billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + catalogRevision: integer("catalog_revision").notNull(), + offerKey: text("offer_key").notNull(), + plan: text("plan").notNull(), + billingInterval: text("billing_interval").notNull(), + status: text("status").notNull().default("pending"), + currentPeriodStartsAt: timestamp("current_period_starts_at", { + withTimezone: true, + }), + currentPeriodEndsAt: timestamp("current_period_ends_at", { + withTimezone: true, + }), + paidThroughAt: timestamp("paid_through_at", { withTimezone: true }), + trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }), + cancelAtPeriodEnd: boolean("cancel_at_period_end") + .notNull() + .default(false), + isEntitlementSource: boolean("is_entitlement_source") + .notNull() + .default(false), + providerOccurredAt: timestamp("provider_occurred_at", { + withTimezone: true, + }), + providerVersion: text("provider_version"), + lastObservedAt: timestamp("last_observed_at", { withTimezone: true }), + lastReconciledAt: timestamp("last_reconciled_at", { + withTimezone: true, + }), + ...timestamps, + pastDueAt: timestamp("past_due_at", { withTimezone: true }), + graceEndsAt: timestamp("grace_ends_at", { withTimezone: true }), + }, + (table) => ({ + providerSubscriptionUnique: uniqueIndex( + "billing_subscriptions_provider_subscription_uidx", + ).on(table.provider, table.providerSubscriptionId), + entitySourceUnique: uniqueIndex( + "billing_subscriptions_entity_source_uidx", + ) + .on(table.billableEntityId) + .where(sql`${table.isEntitlementSource} = true`), + statusCheck: check( + "billing_subscriptions_status_check", + sql`${table.status} IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')`, + ), + planCheck: check( + "billing_subscriptions_plan_check", + sql`${table.plan} IN ('pro', 'business')`, + ), + intervalCheck: check( + "billing_subscriptions_interval_check", + sql`${table.billingInterval} IN ('month', 'year')`, + ), + }), +); + +export const billingPlanChangeAttempts = pgTable( + "billing_plan_change_attempts", + { + id: uuid("id").primaryKey().defaultRandom(), + changeId: text("change_id").notNull().unique(), + billableEntityId: uuid("billable_entity_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + subscriptionId: uuid("subscription_id") + .notNull() + .references(() => billingSubscriptions.id, { + onDelete: "restrict", + }), + actorId: text("actor_id").notNull(), + payerId: text("payer_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + provider: text("provider").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + currentCatalogRevision: integer("current_catalog_revision").notNull(), + currentBillingPriceEntryId: uuid("current_billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + currentPlan: text("current_plan").notNull(), + currentInterval: text("current_interval").notNull(), + targetCatalogRevision: integer("target_catalog_revision").notNull(), + targetBillingPriceEntryId: uuid("target_billing_price_entry_id") + .notNull() + .references(() => billingPriceEntries.id, { onDelete: "restrict" }), + targetPlan: text("target_plan").notNull(), + targetInterval: text("target_interval").notNull(), + targetOfferKey: text("target_offer_key").notNull(), + effectiveAt: text("effective_at").notNull(), + prorationMode: text("proration_mode").notNull(), + providerPaymentId: text("provider_payment_id"), + paymentUrlEncrypted: text("payment_url_encrypted"), + status: text("status").notNull().default("creating"), + lastError: text("last_error"), + completedAt: timestamp("completed_at", { withTimezone: true }), + ...timestamps, + }, + (table) => ({ + idempotencyUnique: uniqueIndex( + "billing_plan_change_attempts_idempotency_uidx", + ).on(table.idempotencyKey), + entityNonterminalUnique: uniqueIndex( + "billing_plan_change_attempts_entity_nonterminal_uidx", + ) + .on(table.billableEntityId) + .where(sql`${table.status} IN ('creating', 'pending')`), + statusCheck: check( + "billing_plan_change_attempts_status_check", + sql`${table.status} IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')`, + ), + effectiveAtCheck: check( + "billing_plan_change_attempts_effective_at_check", + sql`${table.effectiveAt} IN ('immediately', 'next_billing_date')`, + ), + prorationModeCheck: check( + "billing_plan_change_attempts_proration_mode_check", + sql`${table.prorationMode} IN ('prorated_immediately', 'do_not_bill')`, + ), + currentPlanCheck: check( + "billing_plan_change_attempts_current_plan_check", + sql`${table.currentPlan} IN ('pro', 'business')`, + ), + targetPlanCheck: check( + "billing_plan_change_attempts_target_plan_check", + sql`${table.targetPlan} IN ('pro', 'business')`, + ), + }), +); + +export const billingPlanStates = pgTable("billing_plan_states", { + id: uuid("id").primaryKey().defaultRandom(), + billableEntityId: uuid("billable_entity_id") + .notNull() + .unique() + .references(() => organizations.id, { onDelete: "restrict" }), + activeSubscriptionId: uuid("active_subscription_id").references( + () => billingSubscriptions.id, + { onDelete: "restrict" }, + ), + projectionVersion: integer("projection_version").notNull().default(0), + ...timestamps, + plan: text("plan").notNull(), + teamsLimitOverride: integer("teams_limit_override"), + contactsLimitOverride: integer("contacts_limit_override"), + firstPaidActivatedAt: timestamp("first_paid_activated_at", { + withTimezone: true, + }), + rampStage: integer("ramp_stage").notNull(), + rampCleanStageDays: integer("ramp_clean_stage_days").notNull(), + rampEvaluatedAt: timestamp("ramp_evaluated_at", { withTimezone: true }), +}); + +export const billingWebhookEvents = pgTable( + "billing_webhook_events", + { + id: uuid("id").primaryKey().defaultRandom(), + provider: text("provider").notNull(), + providerEventId: text("provider_event_id").notNull(), + eventType: text("event_type").notNull(), + occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(), + subscriptionId: text("subscription_id"), + checkoutAttemptId: text("checkout_attempt_id"), + payloadEncrypted: text("payload_encrypted"), + payloadKeyVersion: text("payload_key_version"), + verifiedKeyVersion: text("verified_key_version"), + status: text("status").notNull().default("pending"), + processingAttempts: integer("processing_attempts").notNull().default(0), + lastError: text("last_error"), + availableAt: timestamp("available_at", { withTimezone: true }) + .notNull() + .defaultNow(), + lockedAt: timestamp("locked_at", { withTimezone: true }), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + workerId: text("worker_id"), + receivedAt: timestamp("received_at", { withTimezone: true }) + .notNull() + .defaultNow(), + processedAt: timestamp("processed_at", { withTimezone: true }), + }, + (table) => ({ + providerEventUnique: uniqueIndex( + "billing_webhook_events_provider_event_uidx", + ).on(table.provider, table.providerEventId), + queueIdx: index("billing_webhook_events_queue_idx").on( + table.status, + table.availableAt, + ), + statusCheck: check( + "billing_webhook_events_status_check", + sql`${table.status} IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')`, + ), + }), +); + +export const billingReconciliationJobs = pgTable( + "billing_reconciliation_jobs", + { + id: uuid("id").primaryKey().defaultRandom(), + provider: text("provider").notNull(), + checkoutAttemptId: uuid("checkout_attempt_id").references( + () => billingCheckoutAttempts.id, + { onDelete: "restrict" }, + ), + planChangeAttemptId: uuid("plan_change_attempt_id").references( + () => billingPlanChangeAttempts.id, + { onDelete: "restrict" }, + ), + subscriptionId: uuid("subscription_id").references( + () => billingSubscriptions.id, + { onDelete: "restrict" }, + ), + providerCustomerId: uuid("provider_customer_id").references( + () => billingProviderCustomers.id, + { onDelete: "restrict" }, + ), + operation: text("operation").notNull().default("reconcile"), + status: text("status").notNull().default("pending"), + attemptCount: integer("attempt_count").notNull().default(0), + availableAt: timestamp("available_at", { withTimezone: true }) + .notNull() + .defaultNow(), + lockedAt: timestamp("locked_at", { withTimezone: true }), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + workerId: text("worker_id"), + lastError: text("last_error"), + ...timestamps, + }, + (table) => ({ + exactlyOneSubject: check( + "billing_reconciliation_jobs_exactly_one_subject", + sql`((CASE WHEN ${table.checkoutAttemptId} IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN ${table.planChangeAttemptId} IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN ${table.subscriptionId} IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN ${table.providerCustomerId} IS NOT NULL THEN 1 ELSE 0 END)) = 1`, + ), + liveCheckoutUnique: uniqueIndex( + "billing_reconciliation_jobs_live_checkout_uidx", + ) + .on(table.checkoutAttemptId) + .where( + sql`${table.checkoutAttemptId} IS NOT NULL AND ${table.status} IN ('pending', 'processing', 'failed')`, + ), + livePlanChangeUnique: uniqueIndex( + "billing_reconciliation_jobs_live_plan_change_uidx", + ) + .on(table.planChangeAttemptId) + .where( + sql`${table.planChangeAttemptId} IS NOT NULL AND ${table.status} IN ('pending', 'processing', 'failed')`, + ), + liveSubscriptionUnique: uniqueIndex( + "billing_reconciliation_jobs_live_subscription_uidx", + ) + .on(table.subscriptionId) + .where( + sql`${table.subscriptionId} IS NOT NULL AND ${table.status} IN ('pending', 'processing', 'failed')`, + ), + liveCustomerUnique: uniqueIndex( + "billing_reconciliation_jobs_live_customer_uidx", + ) + .on(table.providerCustomerId) + .where( + sql`${table.providerCustomerId} IS NOT NULL AND ${table.status} IN ('pending', 'processing', 'failed')`, + ), + statusCheck: check( + "billing_reconciliation_jobs_status_check", + sql`${table.status} IN ('pending', 'processing', 'failed', 'completed', 'quarantined')`, + ), + operationCheck: check( + "billing_reconciliation_jobs_operation_check", + sql`${table.operation} = 'reconcile' OR (${table.operation} = 'cancellation' AND ${table.subscriptionId} IS NOT NULL)`, + ), + }), +); diff --git a/apps/api/src/db/schema-core.ts b/apps/api/src/db/schema-core.ts new file mode 100644 index 0000000..4cbec73 --- /dev/null +++ b/apps/api/src/db/schema-core.ts @@ -0,0 +1,2174 @@ +import { + pgTable, + uuid, + text, + timestamp, + integer, + bigint, + boolean, + doublePrecision, + jsonb, + index, + uniqueIndex, + unique, + check, + foreignKey, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import type { CustomFields } from "@sendlit/api-contract"; +import { genId, genPublicId } from "./id"; + +/** A `CHECK` enforcing that a public-id column always carries its resource's + * prefix (`cnt_`, `seq_`, ...) — defense in depth alongside `genPublicId()`, + * so a malformed value can never be inserted even by code that bypasses it + * (a script, a future migration, direct SQL). */ +function publicIdCheck(name: string, column: any, prefix: string) { + // The pattern must be inlined as a literal, not a bound parameter — + // drizzle-kit emits `CHECK` clauses verbatim into the migration SQL file, + // which is replayed later with no params to bind against (`$1` would be + // dangling, invalid SQL). `prefix` is always a trusted internal literal + // (never user input), so `sql.raw` here is safe. + return check(name, sql`${column} ~ ${sql.raw(`'^${prefix}_'`)}`); +} + +/** + * ID convention used across every table in this schema: + * + * - `id` is an internal-only UUIDv7 surrogate primary key. It is what every + * foreign key in this file references, and it is never returned by any + * REST/MCP response (see `utils/public.ts`). UUIDv7 keeps inserts roughly + * time-ordered, unlike UUIDv4/`gen_random_uuid()`, which scatters inserts + * randomly across the primary key's B-tree. + * - `_id` (e.g. `contact_id`, `sequence_id`) is the public-facing + * identifier: `_<24 random chars>`, generated by `genPublicId()`. + * It's the only ID ever exposed to API/MCP consumers, and the only one + * ever accepted back from them to address a row. + * + * Every foreign key in this file — including on the event/log tables + * (`ongoing_sequences`, `email_deliveries`, `email_events`, `rules`) and + * `contact_custom_field_values` — references the target row's internal `id`, + * with `ON DELETE CASCADE`. Write paths that only have the public id on hand + * (tracking pixels, rule processing) resolve public → internal id first. + */ + +/** Durable customer/administration boundary above teams. */ +export const organizations = pgTable( + "organizations", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: text("organization_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("org")), + name: text("name").notNull(), + status: text("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationIdCheck: publicIdCheck( + "organizations_organization_id_check", + table.organizationId, + "org", + ), + statusCheck: check( + "organizations_status_check", + sql`${table.status} IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')`, + ), + }), +); + +/** Better Auth's default human identity model and table. */ +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").notNull().default(false), + image: text("image"), + defaultOrganizationId: uuid("default_organization_id").references( + () => organizations.id, + { onDelete: "set null" }, + ), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), +}); + +export const planSendUsageBuckets = pgTable( + "plan_send_usage_buckets", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + bucketMonth: timestamp("bucket_month", { + withTimezone: true, + }).notNull(), + committed: integer("committed").notNull().default(0), + reserved: integer("reserved").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationMonthUnique: uniqueIndex( + "plan_send_usage_buckets_organization_month_uidx", + ).on(table.organizationId, table.bucketMonth), + countCheck: check( + "plan_send_usage_buckets_count_check", + sql`${table.committed} >= 0 AND ${table.reserved} >= 0`, + ), + }), +); + +export const planSendReservations = pgTable( + "plan_send_reservations", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + outboundMessageId: uuid("outbound_message_id").notNull(), + bucketId: uuid("bucket_id") + .notNull() + .references(() => planSendUsageBuckets.id, { + onDelete: "restrict", + }), + amount: integer("amount").notNull().default(1), + state: text("state").notNull().default("reserved"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + committedAt: timestamp("committed_at", { withTimezone: true }), + releasedAt: timestamp("released_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + outboundUnique: uniqueIndex("plan_send_reservations_outbound_uidx").on( + table.outboundMessageId, + ), + expiryIdx: index("plan_send_reservations_expiry_idx").on( + table.state, + table.expiresAt, + ), + amountCheck: check( + "plan_send_reservations_amount_check", + sql`${table.amount} > 0`, + ), + stateCheck: check( + "plan_send_reservations_state_check", + sql`${table.state} IN ('reserved', 'committed', 'released')`, + ), + }), +); + +export const teamSendingControls = pgTable( + "team_sending_controls", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .unique() + .references(() => teams.id, { onDelete: "restrict" }), + status: text("status").notNull().default("normal"), + reasonCode: text("reason_code"), + source: text("source").notNull().default("automatic"), + enteredAt: timestamp("entered_at", { withTimezone: true }), + evaluatedAt: timestamp("evaluated_at", { withTimezone: true }), + minimumHoldUntil: timestamp("minimum_hold_until", { + withTimezone: true, + }), + operatorUserId: text("operator_user_id").references(() => user.id, { + onDelete: "restrict", + }), + operatorReason: text("operator_reason"), + overriddenAt: timestamp("overridden_at", { withTimezone: true }), + cleanEvaluationDays: integer("clean_evaluation_days") + .notNull() + .default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + statusCheck: check( + "team_sending_controls_status_check", + sql`${table.status} IN ('normal', 'warned', 'marketing_paused', 'all_paused')`, + ), + sourceCheck: check( + "team_sending_controls_source_check", + sql`${table.source} IN ('automatic', 'operator')`, + ), + cleanDaysCheck: check( + "team_sending_controls_clean_days_check", + sql`${table.cleanEvaluationDays} >= 0`, + ), + }), +); + +export const sendingDomains = pgTable( + "sending_domains", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + domainId: text("domain_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("domain")), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + domain: text("domain").notNull(), + challengeTokenHash: text("challenge_token_hash").notNull(), + status: text("status").notNull().default("pending"), + verifiedAt: timestamp("verified_at", { withTimezone: true }), + lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }), + nextCheckAt: timestamp("next_check_at", { withTimezone: true }), + failedCheckCount: integer("failed_check_count").notNull().default(0), + firstFailedAt: timestamp("first_failed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + domainIdCheck: publicIdCheck( + "sending_domains_domain_id_check", + table.domainId, + "domain", + ), + organizationDomainUnique: uniqueIndex( + "sending_domains_organization_domain_uidx", + ).on(table.organizationId, table.domain), + statusCheck: check( + "sending_domains_status_check", + sql`${table.status} IN ('pending', 'verified', 'revoked', 'failed')`, + ), + failedCheckCountCheck: check( + "sending_domains_failed_check_count_check", + sql`${table.failedCheckCount} >= 0`, + ), + }), +); + +/** Explicit organization authorization; authentication alone grants nothing. */ +export const organizationMembers = pgTable( + "organization_members", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + role: text("role").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationUserIdx: uniqueIndex( + "organization_members_organization_id_user_id_idx", + ).on(table.organizationId, table.userId), + roleCheck: check( + "organization_members_role_check", + sql`${table.role} IN ('owner', 'admin', 'member')`, + ), + }), +); + +/** Immutable operational record for organization administration and + * integration-driven lifecycle changes. It intentionally stores references + * rather than secrets or raw API keys. */ +export const organizationAuditEvents = pgTable( + "organization_audit_events", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + actorType: text("actor_type").notNull(), // user|organization_key|team_key|system + actorId: text("actor_id"), + action: text("action").notNull(), + teamId: uuid("team_id"), + espConfigId: uuid("esp_config_id"), + espGrantId: uuid("esp_grant_id"), + metadata: jsonb("metadata").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationCreatedIdx: index( + "organization_audit_events_organization_id_created_at_idx", + ).on(table.organizationId, table.createdAt), + teamCreatedIdx: index( + "organization_audit_events_team_id_created_at_idx", + ).on(table.teamId, table.createdAt), + }), +); + +/** Team/workspace and email-data boundary. Every team belongs to one org. */ +export const teams = pgTable( + "teams", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: text("team_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("team")), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + externalId: text("external_id"), + provisioningRequestHash: text("provisioning_request_hash"), + name: text("name").notNull(), + status: text("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + teamIdCheck: publicIdCheck("teams_team_id_check", table.teamId, "team"), + organizationExternalIdIdx: uniqueIndex( + "teams_organization_id_external_id_idx", + ) + .on(table.organizationId, table.externalId) + .where(sql`${table.externalId} IS NOT NULL`), + idOrganizationIdx: unique("teams_id_organization_id_unique").on( + table.id, + table.organizationId, + ), + statusCheck: check( + "teams_status_check", + sql`${table.status} IN ('active', 'sending_suspended', 'archived')`, + ), + }), +); + +/** Team membership is independent from organization membership. */ +export const teamMembers = pgTable( + "team_members", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + role: text("role").notNull().default("member"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + teamUserIdx: uniqueIndex("team_members_team_id_user_id_idx").on( + table.teamId, + table.userId, + ), + roleCheck: check( + "team_members_role_check", + sql`${table.role} IN ('admin', 'member')`, + ), + }), +); + +/** Better Auth's remaining default core models/tables. */ +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => ({ + userIdIdx: index("auth_session_user_id_idx").on(table.userId), + }), +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + // Better Auth 1.7 account key is (issuer, accountId). + issuer: text("issuer").notNull(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { + withTimezone: true, + }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { + withTimezone: true, + }), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (table) => ({ + userIdIdx: index("auth_account_user_id_idx").on(table.userId), + issuerAccountIdx: uniqueIndex("auth_account_issuer_account_id_uidx").on( + table.issuer, + table.accountId, + ), + }), +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (table) => ({ + identifierIdx: index("auth_verification_identifier_idx").on( + table.identifier, + ), + }), +); + +export const jwks = pgTable("jwks", { + id: text("id").primaryKey(), + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + // Required by Better Auth's JWT plugin to select a signing key for a + // configured algorithm/curve. Existing keys inherit the default + // algorithm when these nullable fields are absent. + alg: text("alg"), + crv: text("crv"), +}); + +export const oauthClient = pgTable( + "oauth_client", + { + id: text("id").primaryKey(), + clientId: text("client_id").notNull().unique(), + clientSecret: text("client_secret"), + // Required for CIMD ownership and refresh. Discovery-owned clients + // must not be mutable through managed-client paths. + clientDiscoveryId: text("client_discovery_id"), + disabled: boolean("disabled").default(false), + skipConsent: boolean("skip_consent"), + enableEndSession: boolean("enable_end_session"), + subjectType: text("subject_type"), + scopes: text("scopes").array(), + clientCredentialsScopes: text("client_credentials_scopes") + .array() + .notNull() + .default([]), + userId: text("user_id").references(() => user.id, { + onDelete: "cascade", + }), + createdAt: timestamp("created_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }), + name: text("name"), + uri: text("uri"), + icon: text("icon"), + contacts: text("contacts").array(), + tos: text("tos"), + policy: text("policy"), + softwareId: text("software_id"), + softwareVersion: text("software_version"), + softwareStatement: text("software_statement"), + redirectUris: text("redirect_uris").array().notNull(), + postLogoutRedirectUris: text("post_logout_redirect_uris").array(), + backchannelLogoutUri: text("backchannel_logout_uri"), + backchannelLogoutSessionRequired: boolean( + "backchannel_logout_session_required", + ), + tokenEndpointAuthMethod: text("token_endpoint_auth_method"), + applicationType: text("application_type"), + jwks: text("jwks"), + jwksUri: text("jwks_uri"), + grantTypes: text("grant_types").array(), + responseTypes: text("response_types").array(), + public: boolean("public"), + type: text("type"), + requirePKCE: boolean("require_pkce"), + dpopBoundAccessTokens: boolean("dpop_bound_access_tokens").default( + false, + ), + referenceId: text("reference_id"), + metadata: jsonb("metadata"), + }, + (table) => ({ + userIdIdx: index("auth_oauth_client_user_id_idx").on(table.userId), + }), +); + +/** Better Auth OAuth Provider's persistent protected-resource registry. + * CIMD authorization uses this to bind the MCP resource indicator to its + * allowed scopes and token policy. */ +export const oauthResource = pgTable("oauth_resource", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull().unique(), + name: text("name").notNull(), + accessTokenTtl: integer("access_token_ttl"), + refreshTokenTtl: integer("refresh_token_ttl"), + signingAlgorithm: text("signing_algorithm"), + signingKeyId: text("signing_key_id"), + allowedScopes: text("allowed_scopes").array(), + customClaims: jsonb("custom_claims"), + dpopBoundAccessTokensRequired: boolean("dpop_bound_access_tokens_required") + .notNull() + .default(false), + disabled: boolean("disabled").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }), + policyVersion: integer("policy_version").notNull().default(1), + metadata: jsonb("metadata"), +}); + +/** Optional per-client resource linkage used by Better Auth's OAuth provider. + * The provider keeps this table even when resource enforcement is currently + * permissive, so future policy tightening needs no schema rewrite. */ +export const oauthClientResource = pgTable( + "oauth_client_resource", + { + id: text("id").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + resourceId: text("resource_id") + .notNull() + .references(() => oauthResource.identifier, { + onDelete: "cascade", + }), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true }), + }, + (table) => ({ + clientIdIdx: index("auth_oauth_client_resource_client_id_idx").on( + table.clientId, + ), + resourceIdIdx: index("auth_oauth_client_resource_resource_id_idx").on( + table.resourceId, + ), + clientResourceUnique: uniqueIndex( + "auth_oauth_client_resource_client_id_resource_id_idx", + ).on(table.clientId, table.resourceId), + }), +); + +export const oauthRefreshToken = pgTable( + "oauth_refresh_token", + { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { + onDelete: "cascade", + }), + sessionId: text("session_id").references(() => session.id, { + onDelete: "set null", + }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + authorizationCodeId: text("authorization_code_id"), + resources: text("resources").array(), + requestedUserInfoClaims: text("requested_user_info_claims").array(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + revoked: timestamp("revoked", { withTimezone: true }), + rotatedAt: timestamp("rotated_at", { withTimezone: true }), + rotationReplayResponse: text("rotation_replay_response"), + rotationReplayExpiresAt: timestamp("rotation_replay_expires_at", { + withTimezone: true, + }), + authTime: timestamp("auth_time", { withTimezone: true }), + confirmation: jsonb("confirmation"), + scopes: text("scopes").array().notNull(), + }, + (table) => ({ + clientIdIdx: index("auth_oauth_refresh_token_client_id_idx").on( + table.clientId, + ), + authorizationCodeIdIdx: index( + "auth_oauth_refresh_token_authorization_code_id_idx", + ).on(table.authorizationCodeId), + sessionIdIdx: index("auth_oauth_refresh_token_session_id_idx").on( + table.sessionId, + ), + userIdIdx: index("auth_oauth_refresh_token_user_id_idx").on( + table.userId, + ), + }), +); + +export const oauthAccessToken = pgTable( + "oauth_access_token", + { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { + onDelete: "cascade", + }), + sessionId: text("session_id").references(() => session.id, { + onDelete: "set null", + }), + userId: text("user_id").references(() => user.id, { + onDelete: "cascade", + }), + referenceId: text("reference_id"), + authorizationCodeId: text("authorization_code_id"), + resources: text("resources").array(), + requestedUserInfoClaims: text("requested_user_info_claims").array(), + refreshId: text("refresh_id").references(() => oauthRefreshToken.id, { + onDelete: "set null", + }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + scopes: text("scopes").array().notNull(), + confirmation: jsonb("confirmation"), + }, + (table) => ({ + clientIdIdx: index("auth_oauth_access_token_client_id_idx").on( + table.clientId, + ), + sessionIdIdx: index("auth_oauth_access_token_session_id_idx").on( + table.sessionId, + ), + userIdIdx: index("auth_oauth_access_token_user_id_idx").on( + table.userId, + ), + authorizationCodeIdIdx: index( + "auth_oauth_access_token_authorization_code_id_idx", + ).on(table.authorizationCodeId), + refreshIdIdx: index("auth_oauth_access_token_refresh_id_idx").on( + table.refreshId, + ), + }), +); + +export const oauthConsent = pgTable( + "oauth_consent", + { + id: text("id").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { + onDelete: "cascade", + }), + userId: text("user_id").references(() => user.id, { + onDelete: "cascade", + }), + referenceId: text("reference_id"), + resources: text("resources").array(), + requestedUserInfoClaims: text("requested_user_info_claims").array(), + scopes: text("scopes").array().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (table) => ({ + clientIdIdx: index("auth_oauth_consent_client_id_idx").on( + table.clientId, + ), + userIdIdx: index("auth_oauth_consent_user_id_idx").on(table.userId), + }), +); + +/** Single-use identifiers for private_key_jwt client assertions. The local + * MCP client is public and does not use these, but keeping the provider's + * complete schema makes the configured plugin safe to extend. */ +export const oauthClientAssertion = pgTable("oauth_client_assertion", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), +}); + +/** The team an OAuth end-user picked on the post-login "select a team" screen + * (`/oauth/select-team`, shown only when their account belongs to more than + * one team — mirrors Notion's workspace picker). One row per Better Auth + * session, written when the user submits their choice and read back by + * `oauthProvider`'s `postLogin.consentReferenceId` hook (see + * `auth/better-auth.ts`), which threads it through as the OAuth `referenceId` + * so it ends up on the minted access token's `team_id` claim + * (`customAccessTokenClaims`). Without this, a generic OAuth/MCP client has no + * way to tell SendLit which team to scope its requests to — there is no + * standard OAuth mechanism for it, and the custom `X-Sendlit-Team-Id` header + * only works for clients SendLit itself controls (the web dashboard). */ +export const oauthPostLoginTeamSelections = pgTable( + "oauth_post_login_team_selections", + { + sessionId: text("session_id") + .primaryKey() + .references(() => session.id, { onDelete: "cascade" }), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, +); + +/** Organization keys provision/manage resources only inside one organization. */ +export const organizationApiKeys = pgTable( + "organization_api_keys", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationApiKeyId: text("organization_api_key_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("oak")), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text("name").notNull(), + keyHash: text("key_hash").notNull().unique(), + keyPrefix: text("key_prefix").notNull(), + scopes: text("scopes").array().notNull().default([]), + expiresAt: timestamp("expires_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdByUserId: text("created_by_user_id").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + organizationApiKeyIdCheck: publicIdCheck( + "organization_api_keys_public_id_check", + table.organizationApiKeyId, + "oak", + ), + organizationIdx: index("organization_api_keys_organization_id_idx").on( + table.organizationId, + ), + }), +); + +/** A team key authenticates as exactly one team and never as a user/org. */ +export const teamApiKeys = pgTable( + "team_api_keys", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamApiKeyId: text("team_api_key_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("tak")), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + keyHash: text("key_hash").notNull().unique(), + keyPrefix: text("key_prefix").notNull(), + name: text("name").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdByType: text("created_by_type").notNull().default("user"), + createdById: text("created_by_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + teamApiKeyIdCheck: publicIdCheck( + "team_api_keys_public_id_check", + table.teamApiKeyId, + "tak", + ), + teamIdx: index("team_api_keys_team_id_idx").on(table.teamId), + createdByTypeCheck: check( + "team_api_keys_created_by_type_check", + sql`${table.createdByType} IN ('user', 'organization_key', 'system')`, + ), + }), +); + +/** A contact is a recipient/subscriber. Equivalent of CourseLit's `User` model, + * stripped of everything course/product related. `contactId` is the public + * handle (`cnt_...`); `id` is internal-only. */ +export const contacts = pgTable( + "contacts", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + contactId: text("contact_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("cnt")), + email: text("email").notNull(), + name: text("name"), + subscribed: boolean("subscribed").notNull().default(true), + // Intentionally kept alongside `contact_custom_field_values`: this + // jsonb is the denormalized public read/render snapshot (API + // responses, merge tags), while the table is the indexed store used + // for segmentation queries. + customFields: jsonb("custom_fields") + .$type() + .notNull() + .default({}), + tags: text("tags").array().notNull().default([]), + unsubscribeToken: text("unsubscribe_token").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + teamEmailIdx: uniqueIndex("contacts_team_id_email_idx").on( + table.teamId, + table.email, + ), + contactIdCheck: publicIdCheck( + "contacts_contact_id_check", + table.contactId, + "cnt", + ), + }), +); + +/** Indexed custom field values for scalable generic contact segmentation. + * `contacts.customFields` remains the public/read snapshot; this table stores + * one row per scalar value, including each element of scalar arrays. + * `contactId` here references the contact's *internal* id. */ +export const contactCustomFieldValues = pgTable( + "contact_custom_field_values", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + contactId: uuid("contact_id") + .notNull() + .references(() => contacts.id, { onDelete: "cascade" }), + key: text("key").notNull(), + valueType: text("value_type").notNull(), // string | number | boolean | date + valueText: text("value_text"), + valueNumber: doublePrecision("value_number"), + valueBoolean: boolean("value_boolean"), + valueDate: timestamp("value_date", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + contactKeyIdx: index("contact_custom_field_values_contact_key_idx").on( + table.teamId, + table.contactId, + table.key, + ), + textLookupIdx: index("contact_custom_field_values_text_lookup_idx").on( + table.teamId, + table.key, + table.valueText, + ), + numberLookupIdx: index( + "contact_custom_field_values_number_lookup_idx", + ).on(table.teamId, table.key, table.valueNumber), + booleanLookupIdx: index( + "contact_custom_field_values_boolean_lookup_idx", + ).on(table.teamId, table.key, table.valueBoolean), + dateLookupIdx: index("contact_custom_field_values_date_lookup_idx").on( + table.teamId, + table.key, + table.valueDate, + ), + }), +); + +export const emailTemplates = pgTable( + "email_templates", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + templateId: text("template_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("tpl")), + title: text("title").notNull(), + purpose: text("purpose", { + enum: ["marketing", "transactional"], + }) + .notNull() + .default("marketing"), + // { content: EmailBlock[], style: EmailStyle, meta: EmailMeta } — see @sendlit/email-editor + content: jsonb("content").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + teamTitleIdx: uniqueIndex("email_templates_team_id_title_idx").on( + table.teamId, + table.title, + ), + templateIdCheck: publicIdCheck( + "email_templates_template_id_check", + table.templateId, + "tpl", + ), + purposeCheck: check( + "email_templates_purpose_check", + sql`${table.purpose} in ('marketing', 'transactional')`, + ), + }), +); + +export const media = pgTable( + "media", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + mediaId: text("media_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("med")), + mediaLitId: text("media_lit_id").notNull(), + url: text("url").notNull(), + thumbnailUrl: text("thumbnail_url"), + fileName: text("file_name"), + mimeType: text("mime_type"), + size: integer("size"), + width: integer("width"), + height: integer("height"), + alt: text("alt"), + caption: text("caption"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + mediaIdCheck: publicIdCheck( + "media_media_id_check", + table.mediaId, + "med", + ), + teamMediaLitIdx: uniqueIndex("media_team_id_media_lit_id_idx").on( + table.teamId, + table.mediaLitId, + ), + teamCreatedAtIdx: index("media_team_id_created_at_idx").on( + table.teamId, + table.createdAt, + ), + }), +); + +export const mediaReferences = pgTable( + "media_references", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + mediaId: uuid("media_id") + .notNull() + .references(() => media.id, { onDelete: "cascade" }), + resourceType: text("resource_type").notNull(), + resourceInternalId: uuid("resource_internal_id").notNull(), + resourcePublicId: text("resource_public_id").notNull(), + parentResourceInternalId: uuid("parent_resource_internal_id"), + parentResourcePublicId: text("parent_resource_public_id"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + resourceIdx: index("media_references_resource_idx").on( + table.teamId, + table.resourceType, + table.resourceInternalId, + ), + mediaIdx: index("media_references_media_id_idx").on(table.mediaId), + uniqueResourceMediaIdx: uniqueIndex( + "media_references_resource_media_idx", + ).on( + table.teamId, + table.resourceType, + table.resourceInternalId, + table.mediaId, + ), + }), +); + +/** A saved, named, reusable contact filter — the persisted form of the + * `ContactFilterWithAggregator` shape (see `contacts/segment.ts`) that + * `sequences.filter`/`excludeFilter` already store inline per-broadcast. This + * table lets a team build a filter once and reuse it by name instead of + * re-building it for every broadcast/sequence. */ +export const segments = pgTable( + "segments", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + segmentId: text("segment_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("seg")), + name: text("name").notNull(), + // ContactFilterWithAggregator — see contacts/segment.ts + filter: jsonb("filter").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + teamNameIdx: uniqueIndex("segments_team_id_name_idx").on( + table.teamId, + table.name, + ), + segmentIdCheck: publicIdCheck( + "segments_segment_id_check", + table.segmentId, + "seg", + ), + }), +); + +/** One immutable ownership model for organization- and team-owned ESPs. */ +export const espConfigs = pgTable( + "esp_configs", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + espId: text("esp_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("esp")), + ownerScope: text("owner_scope").notNull(), + organizationId: uuid("organization_id").references( + () => organizations.id, + { onDelete: "restrict" }, + ), + teamId: uuid("team_id").references(() => teams.id, { + onDelete: "restrict", + }), + name: text("name").notNull(), + provider: text("provider").notNull().default("smtp"), + host: text("host").notNull(), + port: integer("port").notNull().default(587), + secure: boolean("secure").notNull().default(false), + username: text("username"), + encryptedSecret: text("encrypted_secret"), + fromName: text("from_name"), + fromEmail: text("from_email"), + status: text("status").notNull().default("draft"), + secretVersion: integer("secret_version").notNull().default(1), + lastTestedAt: timestamp("last_tested_at", { withTimezone: true }), + lastTestStatus: text("last_test_status"), // success | failed + lastTestError: text("last_test_error"), + activatedAt: timestamp("activated_at", { withTimezone: true }), + drainUntil: timestamp("drain_until", { withTimezone: true }), + retiredAt: timestamp("retired_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + espIdCheck: publicIdCheck( + "esp_configs_esp_id_check", + table.espId, + "esp", + ), + organizationIdx: index("esp_configs_organization_id_idx").on( + table.organizationId, + ), + teamIdx: index("esp_configs_team_id_idx").on(table.teamId), + idOrganizationIdx: unique("esp_configs_id_organization_id_unique").on( + table.id, + table.organizationId, + ), + idTeamIdx: unique("esp_configs_id_team_id_unique").on( + table.id, + table.teamId, + ), + ownerCheck: check( + "esp_configs_owner_check", + sql`(${table.ownerScope} = 'organization' AND ${table.organizationId} IS NOT NULL AND ${table.teamId} IS NULL) + OR (${table.ownerScope} = 'team' AND ${table.organizationId} IS NULL AND ${table.teamId} IS NOT NULL)`, + ), + statusCheck: check( + "esp_configs_status_check", + sql`${table.status} IN ('draft', 'active', 'suspended', 'draining', 'retired')`, + ), + }), +); + +export const organizationDeliveryPolicies = pgTable( + "organization_delivery_policies", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .unique() + .references(() => organizations.id, { onDelete: "cascade" }), + defaultEspConfigId: uuid("default_esp_config_id"), + autoGrantDefaultEsp: boolean("auto_grant_default_esp") + .notNull() + .default(false), + defaultDailyLimit: integer("default_daily_limit"), + defaultMonthlyLimit: integer("default_monthly_limit"), + aggregateDailyLimit: integer("aggregate_daily_limit"), + aggregateMonthlyLimit: integer("aggregate_monthly_limit"), + teamEspEnabledByDefault: boolean("team_esp_enabled_by_default") + .notNull() + .default(true), + teamCanChangeDefault: boolean("team_can_change_default") + .notNull() + .default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + defaultEspFk: foreignKey({ + name: "organization_delivery_policies_default_esp_fk", + columns: [table.defaultEspConfigId, table.organizationId], + foreignColumns: [espConfigs.id, espConfigs.organizationId], + }).onDelete("restrict"), + limitCheck: check( + "organization_delivery_policies_limit_check", + sql`(${table.defaultDailyLimit} IS NULL OR ${table.defaultDailyLimit} >= 0) + AND (${table.defaultMonthlyLimit} IS NULL OR ${table.defaultMonthlyLimit} >= 0) + AND (${table.aggregateDailyLimit} IS NULL OR ${table.aggregateDailyLimit} >= 0) + AND (${table.aggregateMonthlyLimit} IS NULL OR ${table.aggregateMonthlyLimit} >= 0)`, + ), + }), +); + +export const espConfigTeamGrants = pgTable( + "esp_config_team_grants", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + grantId: text("grant_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("egr")), + organizationId: uuid("organization_id").notNull(), + espConfigId: uuid("esp_config_id").notNull(), + teamId: uuid("team_id").notNull(), + status: text("status").notNull().default("active"), + drainUntil: timestamp("drain_until", { withTimezone: true }), + fromName: text("from_name"), + replyTo: text("reply_to"), + dailyLimit: integer("daily_limit"), + monthlyLimit: integer("monthly_limit"), + createdByType: text("created_by_type").notNull(), + createdById: text("created_by_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + grantIdCheck: publicIdCheck( + "esp_config_team_grants_public_id_check", + table.grantId, + "egr", + ), + activeTeamIdx: uniqueIndex( + "esp_config_team_grants_non_revoked_team_idx", + ) + .on(table.teamId) + .where(sql`${table.status} <> 'revoked'`), + idOrganizationUnique: unique( + "esp_config_team_grants_id_organization_id_unique", + ).on(table.id, table.organizationId), + pinUnique: unique("esp_config_team_grants_id_team_esp_unique").on( + table.id, + table.teamId, + table.espConfigId, + ), + teamOrganizationFk: foreignKey({ + name: "esp_config_team_grants_team_organization_fk", + columns: [table.teamId, table.organizationId], + foreignColumns: [teams.id, teams.organizationId], + }).onDelete("restrict"), + espOrganizationFk: foreignKey({ + name: "esp_config_team_grants_esp_organization_fk", + columns: [table.espConfigId, table.organizationId], + foreignColumns: [espConfigs.id, espConfigs.organizationId], + }).onDelete("restrict"), + statusCheck: check( + "esp_config_team_grants_status_check", + sql`${table.status} IN ('active', 'draining', 'suspended', 'revoked')`, + ), + limitCheck: check( + "esp_config_team_grants_limit_check", + sql`(${table.dailyLimit} IS NULL OR ${table.dailyLimit} >= 0) + AND (${table.monthlyLimit} IS NULL OR ${table.monthlyLimit} >= 0)`, + ), + createdByTypeCheck: check( + "esp_config_team_grants_created_by_type_check", + sql`${table.createdByType} IN ('user', 'organization_key', 'system')`, + ), + }), +); + +export const teamDeliverySettings = pgTable( + "team_delivery_settings", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .unique() + .references(() => teams.id, { onDelete: "cascade" }), + teamEspEnabled: boolean("team_esp_enabled").notNull().default(true), + teamCanChangeDefault: boolean("team_can_change_default") + .notNull() + .default(true), + defaultSource: text("default_source"), + defaultTeamEspConfigId: uuid("default_team_esp_config_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + defaultTeamEspFk: foreignKey({ + name: "team_delivery_settings_default_team_esp_fk", + columns: [table.defaultTeamEspConfigId, table.teamId], + foreignColumns: [espConfigs.id, espConfigs.teamId], + }).onDelete("restrict"), + defaultSourceCheck: check( + "team_delivery_settings_default_source_check", + sql`${table.defaultSource} IS NULL OR ${table.defaultSource} IN ('organization', 'team')`, + ), + }), +); + +/** Per-team general workspace settings ("settings.general") — a per-team + * singleton like `esp_configs`, addressed via the team (`/settings/general`), + * so no public `_id` is needed. `mailingAddress` is the physical + * postal address rendered in email footers (CAN-SPAM/GDPR requirement). */ +export const settings = pgTable("settings", { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .unique() + .references(() => teams.id, { onDelete: "cascade" }), + mailingAddress: text("mailing_address"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), +}); + +/** A broadcast (one-off, `type = 'broadcast'`) or a sequence (multi-step, + * `type = 'sequence'`) — same shape as CourseLit's `Sequence` model. */ +export const sequences = pgTable( + "sequences", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + sequenceId: text("sequence_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("seq")), + type: text("type").notNull(), // 'broadcast' | 'sequence' + title: text("title").notNull().default(""), + status: text("status").notNull().default("draft"), // draft|active|paused|completed + deliverySourceIntent: jsonb("delivery_source_intent"), + // Resolved and pinned atomically at activation. Drafts may leave these + // null while retaining their public source intent in the API layer. + deliverySourceType: text("delivery_source_type"), // organization | team + outboxId: uuid("outbox_id").references(() => espConfigs.id, { + onDelete: "restrict", + }), + espGrantId: uuid("esp_grant_id").references( + () => espConfigTeamGrants.id, + { onDelete: "restrict" }, + ), + triggerType: text("trigger_type"), // Constants.EventType + triggerData: text("trigger_data"), + // UserFilterWithAggregator — see contacts/segment.ts + filter: jsonb("filter"), + excludeFilter: jsonb("exclude_filter"), + emailsOrder: text("emails_order").array().notNull().default([]), + entrants: text("entrants").array().notNull().default([]), + // { broadcast: { sentAt, lockedAt }, sequence: { subscribers, unsubscribers, failed } } + report: jsonb("report").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + sequenceIdCheck: publicIdCheck( + "sequences_sequence_id_check", + table.sequenceId, + "seq", + ), + deliveryPinCheck: check( + "sequences_delivery_pin_check", + sql`( + ${table.deliverySourceType} IS NULL + AND ${table.outboxId} IS NULL + AND ${table.espGrantId} IS NULL + ) OR ( + ${table.deliverySourceType} = 'team' + AND ${table.outboxId} IS NOT NULL + AND ${table.espGrantId} IS NULL + ) OR ( + ${table.deliverySourceType} = 'organization' + AND ${table.outboxId} IS NOT NULL + AND ${table.espGrantId} IS NOT NULL + )`, + ), + }), +); + +/** A structural child of exactly one `sequences` row — never addressed + * independently, so `sequenceId` references the parent's internal `id` + * (unlike the event/log tables below, which store the public id). `emailId` + * is this row's own public handle within that sequence. */ +export const sequenceEmails = pgTable( + "sequence_emails", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + emailId: text("email_id") + .notNull() + .$defaultFn(() => genPublicId("email")), + subject: text("subject").notNull(), + // { content: EmailBlock[], style: EmailStyle, meta: EmailMeta } + content: jsonb("content").notNull(), + delayInMillis: bigint("delay_in_millis", { mode: "number" }) + .notNull() + .default(86400000), + published: boolean("published").notNull().default(false), + templateId: text("template_id"), + actionType: text("action_type"), // tag:add | tag:remove + actionData: jsonb("action_data"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + sequenceEmailIdx: uniqueIndex( + "sequence_emails_sequence_id_email_id_idx", + ).on(table.sequenceId, table.emailId), + emailIdCheck: publicIdCheck( + "sequence_emails_email_id_check", + table.emailId, + "email", + ), + }), +); + +/** A scheduled trigger for a sequence — e.g. "fire DATE_OCCURRED for broadcast X at + * time T", processed by `automation/process-rules.ts`. Not exposed via any + * REST/MCP route today. */ +export const rules = pgTable( + "rules", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + ruleId: text("rule_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("rule")), + event: text("event").notNull(), // Constants.EventType + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + eventDateInMillis: bigint("event_date_in_millis", { mode: "number" }), + eventData: text("event_data"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + ruleIdCheck: publicIdCheck("rules_rule_id_check", table.ruleId, "rule"), + }), +); + +/** One row per (sequence, contact) currently being delivered. Processed by + * `automation/process-ongoing-sequence.ts`. */ +export const ongoingSequences = pgTable( + "ongoing_sequences", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + contactId: uuid("contact_id") + .notNull() + .references(() => contacts.id, { onDelete: "cascade" }), + nextEmailScheduledTime: bigint("next_email_scheduled_time", { + mode: "number", + }).notNull(), + retryCount: integer("retry_count").notNull().default(0), + sentEmailIds: text("sent_email_ids").array().notNull().default([]), + processingStartedAt: timestamp("processing_started_at", { + withTimezone: true, + }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + sequenceContactIdx: uniqueIndex( + "ongoing_sequences_sequence_id_contact_id_idx", + ).on(table.sequenceId, table.contactId), + // The 60s due-poll (`getDueOngoingSequences`) filters on this column. + nextScheduledIdx: index( + "ongoing_sequences_next_email_scheduled_time_idx", + ).on(table.nextEmailScheduledTime), + }), +); + +export const emailDeliveries = pgTable("email_deliveries", { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + contactId: uuid("contact_id") + .notNull() + .references(() => contacts.id, { onDelete: "cascade" }), + emailId: uuid("email_id") + .notNull() + .references(() => sequenceEmails.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); + +export const emailEvents = pgTable("email_events", { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + sequenceId: uuid("sequence_id") + .notNull() + .references(() => sequences.id, { onDelete: "cascade" }), + contactId: uuid("contact_id") + .notNull() + .references(() => contacts.id, { onDelete: "cascade" }), + emailId: uuid("email_id") + .notNull() + .references(() => sequenceEmails.id, { onDelete: "cascade" }), + action: text("action").notNull(), // open | click | bounce + link: text("link"), + linkIndex: integer("link_index"), + bounceType: text("bounce_type"), // hard | soft + bounceReason: text("bounce_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); + +/** A single API-triggered send — the transactional counterpart of + * `sequences`/`sequence_emails`, deliberately not modeled as either (see + * `docs/transactional-emails.md`): recipients are never required to be + * subscribed `contacts`, no unsubscribe/footer is injected, and delivery is + * immediate rather than audience-fanned-out. One row per message; the + * rendered `html` is snapshotted at send time so the log survives later + * template edits/deletes. `toEmail`/`fromEmail` are suffixed `_email` because + * `to`/`from` are reserved words in SQL. */ +export const transactionalEmails = pgTable( + "transactional_emails", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + txeId: text("txe_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("txe")), + deliverySourceType: text("delivery_source_type").notNull(), + outboxId: uuid("outbox_id").references(() => espConfigs.id, { + onDelete: "restrict", + }), + espGrantId: uuid("esp_grant_id").references( + () => espConfigTeamGrants.id, + { onDelete: "restrict" }, + ), + toEmail: text("to_email").notNull(), + // Resolved sender identity at enqueue time (team ESP fromName/fromEmail + // fallback chain, same as `attemptMailSending`) — never caller-supplied. + fromEmail: text("from_email"), + replyTo: text("reply_to"), + subject: text("subject").notNull(), + // Plain text holding the *public* `tpl_` id — same convention as + // `sequence_emails.templateId` (not a FK): informational only, never + // resolved for reads, and left dangling if the template is later + // deleted (harmless — `html`/`subject` are already snapshotted). + templateId: text("template_id"), + // Rendered snapshot actually sent (post-Liquid for template sends, + // verbatim for inline `html` sends) — pre tracking-pixel/click rewrite. + html: text("html"), + // Liquid merge payload; only meaningful alongside `templateId` (inline + // `html` sends are never re-rendered — see PRD's send-pipeline notes). + variables: jsonb("variables").notNull().default({}), + headers: jsonb("headers"), + // Populated opportunistically when `toEmail` matches an existing + // contact, purely for analytics — never consulted for suppression; + // `contacts.subscribed` does not apply to transactional mail. + contactId: uuid("contact_id").references(() => contacts.id, { + onDelete: "set null", + }), + status: text("status").notNull().default("queued"), // queued|sent|failed|bounced|suppressed|cancelled + processingStartedAt: timestamp("processing_started_at", { + withTimezone: true, + }), + error: text("error"), + idempotencyKey: text("idempotency_key"), + trackOpens: boolean("track_opens").notNull().default(false), + trackClicks: boolean("track_clicks").notNull().default(false), + openCount: integer("open_count").notNull().default(0), + clickCount: integer("click_count").notNull().default(0), + sentAt: timestamp("sent_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + txeIdCheck: publicIdCheck( + "transactional_emails_txe_id_check", + table.txeId, + "txe", + ), + // Idempotency-key replay lookup — partial, since most sends won't + // supply one and NULL is never unique-constrained. + teamIdempotencyKeyIdx: uniqueIndex( + "transactional_emails_team_id_idempotency_key_idx", + ) + .on(table.teamId, table.idempotencyKey) + .where(sql`${table.idempotencyKey} IS NOT NULL`), + teamCreatedAtIdx: index( + "transactional_emails_team_id_created_at_idx", + ).on(table.teamId, table.createdAt), + teamStatusIdx: index("transactional_emails_team_id_status_idx").on( + table.teamId, + table.status, + ), + deliveryPinCheck: check( + "transactional_emails_delivery_pin_check", + sql`( + ${table.deliverySourceType} = 'team' + AND ${table.outboxId} IS NOT NULL + AND ${table.espGrantId} IS NULL + ) OR ( + ${table.deliverySourceType} = 'organization' + AND ${table.outboxId} IS NOT NULL + AND ${table.espGrantId} IS NOT NULL + )`, + ), + }), +); + +/** Provider-specific webhook security/health lifecycle for one feedback + * connection — deliberately not columns on `esp_configs`, since secrets and + * health status churn independently of SMTP connection settings (see + * `docs/bounces-and-complaints.md#2-feedback-connection`). A `custom` + * connection is pinned to one team-owned `espConfigId`; a future `platform` + * connection (never created by this phase) has null `teamId`/`espConfigId` + * and is deployment-managed, never returned through team ESP APIs. */ +export const espFeedbackConnections = pgTable( + "esp_feedback_connections", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + connectionId: text("connection_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("whc")), + ownerScope: text("owner_scope").notNull(), // organization | team + organizationId: uuid("organization_id").references( + () => organizations.id, + { onDelete: "restrict" }, + ), + teamId: uuid("team_id").references(() => teams.id, { + onDelete: "restrict", + }), + espConfigId: uuid("esp_config_id").references(() => espConfigs.id, { + onDelete: "restrict", + }), + provider: text("provider").notNull(), + encryptedCredentials: text("encrypted_credentials"), + // Rotation accepts both the current and immediately previous + // credential for up to 24h so an in-flight provider retry signed + // with the old secret isn't rejected (PRD's "Feedback connection" + // rotation requirement). Cleared once expired. + previousEncryptedCredentials: text("previous_encrypted_credentials"), + previousCredentialExpiresAt: timestamp( + "previous_credential_expires_at", + { withTimezone: true }, + ), + // SES: the SNS TopicArn this connection expects notifications from. + expectedTopicArn: text("expected_topic_arn"), + status: text("status").notNull().default("pending"), + lastReceivedAt: timestamp("last_received_at", { withTimezone: true }), + lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }), + lastErrorCode: text("last_error_code"), + disabledAt: timestamp("disabled_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + connectionIdCheck: publicIdCheck( + "esp_feedback_connections_connection_id_check", + table.connectionId, + "whc", + ), + teamIdx: index("esp_feedback_connections_team_id_idx").on(table.teamId), + ownerCheck: check( + "esp_feedback_connections_owner_check", + sql`( + ${table.ownerScope} = 'organization' + AND ${table.organizationId} IS NOT NULL + AND ${table.teamId} IS NULL + ) OR ( + ${table.ownerScope} = 'team' + AND ${table.organizationId} IS NULL + AND ${table.teamId} IS NOT NULL + )`, + ), + // At most one non-retired connection per user ESP — a provider + // change retires the old row (status -> retiring) and inserts a new + // one rather than mutating provider in place. + espConfigActiveIdx: uniqueIndex( + "esp_feedback_connections_esp_config_active_idx", + ) + .on(table.espConfigId) + .where( + sql`${table.espConfigId} is not null and ${table.status} not in ('retiring', 'disabled')`, + ), + }), +); + +/** One row per (recipient) submission across broadcasts, sequences, and + * transactional sends — the common ledger `docs/bounces-and-complaints.md` + * requires so a later provider webhook can correlate back to a workspace, + * source, and pinned ESP regardless of which pipeline sent it. Created + * before transport and updated with the transport result; the current + * `deliveryStatus`/`feedbackStatus` are a projection maintained by + * `feedback/projection.ts`, kept separate from the immutable + * `emailDeliveryEvents` log so retries/out-of-order events can't corrupt it. */ +export const outboundMessages = pgTable( + "outbound_messages", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + messageId: text("message_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("msg")), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + deliverySourceType: text("delivery_source_type").notNull(), + espConfigId: uuid("esp_config_id").references(() => espConfigs.id, { + onDelete: "restrict", + }), + espGrantId: uuid("esp_grant_id").references( + () => espConfigTeamGrants.id, + { onDelete: "restrict" }, + ), + feedbackConnectionId: uuid("feedback_connection_id").references( + () => espFeedbackConnections.id, + { onDelete: "set null" }, + ), + sourceType: text("source_type").notNull(), // campaign | transactional + // Stable application-level submission identity. Retries reuse the + // same ledger row and RFC Message-ID rather than creating a second + // provider-correlatable message. + submissionKey: text("submission_key").unique(), + // Exactly one of these two is populated, matching `sourceType`. + campaignDeliveryId: uuid("campaign_delivery_id").references( + () => emailDeliveries.id, + { onDelete: "set null" }, + ), + transactionalEmailId: uuid("transactional_email_id").references( + () => transactionalEmails.id, + { onDelete: "set null" }, + ), + recipientEmail: text("recipient_email").notNull(), + normalizedRecipient: text("normalized_recipient").notNull(), + // Snapshot of the pinned ESP's provider at send time — never + // resolved from the team's *current* default, so historical + // correlation survives a later default switch. + provider: text("provider"), + rfcMessageId: text("rfc_message_id"), + providerMessageId: text("provider_message_id"), + deliveryStatus: text("delivery_status").notNull().default("queued"), + feedbackStatus: text("feedback_status").notNull().default("none"), + acceptedAt: timestamp("accepted_at", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + bouncedAt: timestamp("bounced_at", { withTimezone: true }), + complainedAt: timestamp("complained_at", { withTimezone: true }), + lastEventAt: timestamp("last_event_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + messageIdCheck: publicIdCheck( + "outbound_messages_message_id_check", + table.messageId, + "msg", + ), + teamCreatedAtIdx: index("outbound_messages_team_id_created_at_idx").on( + table.teamId, + table.createdAt, + ), + connectionProviderMsgIdx: index( + "outbound_messages_connection_provider_msg_idx", + ).on(table.feedbackConnectionId, table.providerMessageId), + recipientHistoryIdx: index( + "outbound_messages_team_id_recipient_created_at_idx", + ).on(table.teamId, table.normalizedRecipient, table.createdAt), + deliveryPinCheck: check( + "outbound_messages_delivery_pin_check", + sql`( + ${table.deliverySourceType} = 'team' + AND ${table.espConfigId} IS NOT NULL + AND ${table.espGrantId} IS NULL + ) OR ( + ${table.deliverySourceType} = 'organization' + AND ${table.espConfigId} IS NOT NULL + AND ${table.espGrantId} IS NOT NULL + ) OR ( + ${table.deliverySourceType} IN ('team', 'organization') + AND ${table.espConfigId} IS NULL + AND ${table.espGrantId} IS NULL + AND ${table.deliveryStatus} <> 'queued' + )`, + ), + }), +); + +/** Atomic quota counters for organization-owned delivery. */ +export const organizationEspUsageBuckets = pgTable( + "organization_esp_usage_buckets", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + bucketScope: text("bucket_scope").notNull(), // grant | organization + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + grantId: uuid("grant_id").references(() => espConfigTeamGrants.id, { + onDelete: "restrict", + }), + periodType: text("period_type").notNull(), // day | month + periodStart: timestamp("period_start", { + withTimezone: true, + }).notNull(), + reservedCount: integer("reserved_count").notNull().default(0), + acceptedCount: integer("accepted_count").notNull().default(0), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + scopeCheck: check( + "organization_esp_usage_buckets_scope_check", + sql`( + ${table.bucketScope} = 'grant' AND ${table.grantId} IS NOT NULL + ) OR ( + ${table.bucketScope} = 'organization' AND ${table.grantId} IS NULL + )`, + ), + periodCheck: check( + "organization_esp_usage_buckets_period_check", + sql`${table.periodType} IN ('day', 'month')`, + ), + countCheck: check( + "organization_esp_usage_buckets_count_check", + sql`${table.reservedCount} >= 0 AND ${table.acceptedCount} >= 0`, + ), + grantPeriodIdx: uniqueIndex( + "organization_esp_usage_buckets_grant_period_idx", + ) + .on(table.grantId, table.periodType, table.periodStart) + .where(sql`${table.grantId} IS NOT NULL`), + organizationPeriodIdx: uniqueIndex( + "organization_esp_usage_buckets_organization_period_idx", + ) + .on(table.organizationId, table.periodType, table.periodStart) + .where(sql`${table.bucketScope} = 'organization'`), + grantOrganizationFk: foreignKey({ + name: "organization_esp_usage_buckets_grant_organization_fk", + columns: [table.grantId, table.organizationId], + foreignColumns: [ + espConfigTeamGrants.id, + espConfigTeamGrants.organizationId, + ], + }).onDelete("restrict"), + }), +); + +export const organizationEspQuotaReservations = pgTable( + "organization_esp_quota_reservations", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + reservationId: text("reservation_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("qrs")), + outboundMessageId: uuid("outbound_message_id") + .notNull() + .unique() + .references(() => outboundMessages.id, { onDelete: "restrict" }), + grantId: uuid("grant_id") + .notNull() + .references(() => espConfigTeamGrants.id, { + onDelete: "restrict", + }), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "restrict" }), + dayPeriodStart: timestamp("day_period_start", { + withTimezone: true, + }).notNull(), + monthPeriodStart: timestamp("month_period_start", { + withTimezone: true, + }).notNull(), + state: text("state").notNull().default("reserved"), + releaseReason: text("release_reason"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + committedAt: timestamp("committed_at", { withTimezone: true }), + releasedAt: timestamp("released_at", { withTimezone: true }), + }, + (table) => ({ + reservationIdCheck: publicIdCheck( + "organization_esp_quota_reservations_reservation_id_check", + table.reservationId, + "qrs", + ), + stateCheck: check( + "organization_esp_quota_reservations_state_check", + sql`${table.state} IN ('reserved', 'committed', 'released')`, + ), + grantOrganizationFk: foreignKey({ + name: "organization_esp_quota_reservations_grant_organization_fk", + columns: [table.grantId, table.organizationId], + foreignColumns: [ + espConfigTeamGrants.id, + espConfigTeamGrants.organizationId, + ], + }).onDelete("restrict"), + }), +); + +/** Transactional hand-off between PostgreSQL and BullMQ. */ +export const mailDispatchOutbox = pgTable( + "mail_dispatch_outbox", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + dispatchId: text("dispatch_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("mdj")), + outboundMessageId: uuid("outbound_message_id") + .notNull() + .unique() + .references(() => outboundMessages.id, { onDelete: "restrict" }), + queueName: text("queue_name").notNull(), + jobName: text("job_name").notNull(), + state: text("state").notNull().default("pending"), + availableAt: timestamp("available_at", { withTimezone: true }) + .notNull() + .defaultNow(), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + publishAttempts: integer("publish_attempts").notNull().default(0), + lastError: text("last_error"), + publishedAt: timestamp("published_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + dispatchIdCheck: publicIdCheck( + "mail_dispatch_outbox_dispatch_id_check", + table.dispatchId, + "mdj", + ), + stateCheck: check( + "mail_dispatch_outbox_state_check", + sql`${table.state} IN ('pending', 'publishing', 'published', 'cancelled')`, + ), + dueIdx: index("mail_dispatch_outbox_due_idx").on( + table.state, + table.availableAt, + ), + }), +); + +/** Durable, authenticated inbox for raw provider webhook bodies — a request + * is inserted here and committed *before* the HTTP response is sent, so an + * acknowledged provider retry can never be lost even if BullMQ/Redis is + * briefly unavailable (see `docs/bounces-and-complaints.md#4-durable-receipt-inbox`). + * `teamId` is only ever populated from a `custom` connection — a platform + * receipt may bundle multiple workspaces in one payload, so team ownership + * is assigned later, per normalized event, from a uniquely matched + * `outboundMessages` row. */ +export const espWebhookReceipts = pgTable( + "esp_webhook_receipts", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + receiptId: text("receipt_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("whr")), + connectionId: uuid("connection_id") + .notNull() + .references(() => espFeedbackConnections.id, { + onDelete: "cascade", + }), + teamId: uuid("team_id").references(() => teams.id, { + onDelete: "cascade", + }), + provider: text("provider").notNull(), + providerRequestId: text("provider_request_id"), + bodySha256: text("body_sha256").notNull(), + // Encrypted raw payload (AES-256-GCM, same utility as ESP + // credentials) — required on receipt, set null only once the + // 30-day raw-retention purge runs (see PRD's privacy/retention). + encryptedPayload: text("encrypted_payload"), + // Allowlisted non-secret headers only — never Authorization, + // Cookie, or signature headers. + safeHeaders: jsonb("safe_headers").notNull().default({}), + status: text("status").notNull().default("pending"), + processingAttempts: integer("processing_attempts").notNull().default(0), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }), + lastErrorCode: text("last_error_code"), + receivedAt: timestamp("received_at", { withTimezone: true }) + .notNull() + .defaultNow(), + processedAt: timestamp("processed_at", { withTimezone: true }), + }, + (table) => ({ + receiptIdCheck: publicIdCheck( + "esp_webhook_receipts_receipt_id_check", + table.receiptId, + "whr", + ), + // The pending-receipt poller's recovery query and the worker's + // claim query both filter on this pair. + statusNextAttemptIdx: index( + "esp_webhook_receipts_status_next_attempt_idx", + ).on(table.status, table.nextAttemptAt), + connectionRequestIdx: index( + "esp_webhook_receipts_connection_id_provider_request_id_idx", + ).on(table.connectionId, table.providerRequestId), + }), +); + +/** One immutable row per canonical provider event, derived from a receipt by + * a provider adapter — the event log `docs/bounces-and-complaints.md` + * requires to keep retries/out-of-order delivery from corrupting the + * `outboundMessages` projection. Never updated after insert. */ +export const emailDeliveryEvents = pgTable( + "email_delivery_events", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + eventId: text("event_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("evt")), + receiptId: uuid("receipt_id") + .notNull() + .references(() => espWebhookReceipts.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id") + .notNull() + .references(() => espFeedbackConnections.id, { + onDelete: "cascade", + }), + // Null until a platform event is uniquely correlated — see + // `outboundMessageId` note below and the PRD's correlation section. + teamId: uuid("team_id").references(() => teams.id, { + onDelete: "cascade", + }), + outboundMessageId: uuid("outbound_message_id").references( + () => outboundMessages.id, + { onDelete: "set null" }, + ), + provider: text("provider").notNull(), + // Deterministic per-adapter idempotency key (e.g. `sg_event_id`, or + // a composed key when the provider doesn't guarantee one) — see + // each adapter's "stable event key" requirement in the PRD. + providerEventKey: text("provider_event_key").notNull(), + providerMessageId: text("provider_message_id"), + recipientEmail: text("recipient_email"), + normalizedRecipient: text("normalized_recipient"), + eventType: text("event_type").notNull(), + bounceClass: text("bounce_class"), + smtpCode: integer("smtp_code"), + enhancedStatusCode: text("enhanced_status_code"), + reason: text("reason"), + remoteMta: text("remote_mta"), + occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(), + receivedAt: timestamp("received_at", { withTimezone: true }).notNull(), + metadata: jsonb("metadata").notNull().default({}), + }, + (table) => ({ + eventIdCheck: publicIdCheck( + "email_delivery_events_event_id_check", + table.eventId, + "evt", + ), + // Event idempotency: replaying the same provider event twice must + // insert nothing the second time. + connectionEventKeyIdx: uniqueIndex( + "email_delivery_events_connection_id_provider_event_key_idx", + ).on(table.connectionId, table.providerEventKey), + teamOccurredAtIdx: index( + "email_delivery_events_team_id_occurred_at_idx", + ).on(table.teamId, table.occurredAt), + outboundMessageIdx: index( + "email_delivery_events_outbound_message_id_idx", + ).on(table.outboundMessageId), + }), +); + +/** Per-workspace do-not-send list — deliberately not derived from + * `contacts.subscribed` or the latest message status, so it survives + * contact deletion/reimport and stays route-independent (any pinned custom + * ESP, and eventually the platform route, must all respect it). One row per + * `(teamId, recipientHash)`; repeated signals update `lastSuppressedAt` and + * keep the strongest `reason` (see `suppressionReasonStrength`). */ +export const emailSuppressions = pgTable( + "email_suppressions", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + suppressionId: text("suppression_id") + .notNull() + .unique() + .$defaultFn(() => genPublicId("sup")), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + // Presented/normalized address — both nulled out (HMAC retained) by + // a recipient privacy-erasure deletion; see PRD's retention section. + recipientEmail: text("recipient_email"), + normalizedRecipient: text("normalized_recipient"), + recipientHash: text("recipient_hash").notNull(), + hashKeyVersion: integer("hash_key_version").notNull(), + reason: text("reason").notNull(), + sourceEventId: uuid("source_event_id").references( + () => emailDeliveryEvents.id, + { onDelete: "set null" }, + ), + active: boolean("active").notNull().default(true), + firstSuppressedAt: timestamp("first_suppressed_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + lastSuppressedAt: timestamp("last_suppressed_at", { + withTimezone: true, + }) + .notNull() + .defaultNow(), + releasedAt: timestamp("released_at", { withTimezone: true }), + releasedBy: text("released_by").references(() => user.id, { + onDelete: "set null", + }), + releaseReason: text("release_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + suppressionIdCheck: publicIdCheck( + "email_suppressions_suppression_id_check", + table.suppressionId, + "sup", + ), + teamHashIdx: uniqueIndex( + "email_suppressions_team_id_recipient_hash_idx", + ).on(table.teamId, table.recipientHash), + teamActiveIdx: index("email_suppressions_team_id_active_idx").on( + table.teamId, + table.active, + ), + }), +); + +/** Append-only audit trail for every suppression create/reason-change/ + * release/reactivate — required so a permitted release is always + * attributable (PRD acceptance criterion: "every permitted release is + * audited"). Never updated or deleted by application code. */ +export const emailSuppressionActions = pgTable( + "email_suppression_actions", + { + id: uuid("id").$defaultFn(genId).primaryKey(), + teamId: uuid("team_id") + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + suppressionId: uuid("suppression_id") + .notNull() + .references(() => emailSuppressions.id, { onDelete: "cascade" }), + sourceEventId: uuid("source_event_id").references( + () => emailDeliveryEvents.id, + { onDelete: "set null" }, + ), + action: text("action").notNull(), // created | reason_changed | released | reactivated + actorType: text("actor_type").notNull(), // system | workspace_user | sendlit_operator + actorUserId: text("actor_user_id").references(() => user.id, { + onDelete: "set null", + }), + explanation: text("explanation"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + suppressionActionsIdx: index( + "email_suppression_actions_suppression_id_created_at_idx", + ).on(table.suppressionId, table.createdAt), + }), +); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index dcc7aa0..8518347 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,2706 +1,3 @@ -import { - pgTable, - uuid, - text, - timestamp, - integer, - bigint, - boolean, - doublePrecision, - jsonb, - index, - uniqueIndex, - unique, - check, - foreignKey, -} from "drizzle-orm/pg-core"; -import { sql } from "drizzle-orm"; -import type { CustomFields } from "@sendlit/api-contract"; -import { genId, genPublicId } from "./id"; - -/** A `CHECK` enforcing that a public-id column always carries its resource's - * prefix (`cnt_`, `seq_`, ...) — defense in depth alongside `genPublicId()`, - * so a malformed value can never be inserted even by code that bypasses it - * (a script, a future migration, direct SQL). */ -function publicIdCheck(name: string, column: any, prefix: string) { - // The pattern must be inlined as a literal, not a bound parameter — - // drizzle-kit emits `CHECK` clauses verbatim into the migration SQL file, - // which is replayed later with no params to bind against (`$1` would be - // dangling, invalid SQL). `prefix` is always a trusted internal literal - // (never user input), so `sql.raw` here is safe. - return check(name, sql`${column} ~ ${sql.raw(`'^${prefix}_'`)}`); -} - -/** - * ID convention used across every table in this schema: - * - * - `id` is an internal-only UUIDv7 surrogate primary key. It is what every - * foreign key in this file references, and it is never returned by any - * REST/MCP response (see `utils/public.ts`). UUIDv7 keeps inserts roughly - * time-ordered, unlike UUIDv4/`gen_random_uuid()`, which scatters inserts - * randomly across the primary key's B-tree. - * - `_id` (e.g. `contact_id`, `sequence_id`) is the public-facing - * identifier: `_<24 random chars>`, generated by `genPublicId()`. - * It's the only ID ever exposed to API/MCP consumers, and the only one - * ever accepted back from them to address a row. - * - * Every foreign key in this file — including on the event/log tables - * (`ongoing_sequences`, `email_deliveries`, `email_events`, `rules`) and - * `contact_custom_field_values` — references the target row's internal `id`, - * with `ON DELETE CASCADE`. Write paths that only have the public id on hand - * (tracking pixels, rule processing) resolve public → internal id first. - */ - -/** Durable customer/administration boundary above teams. */ -export const organizations = pgTable( - "organizations", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: text("organization_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("org")), - name: text("name").notNull(), - status: text("status").notNull().default("active"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - organizationIdCheck: publicIdCheck( - "organizations_organization_id_check", - table.organizationId, - "org", - ), - statusCheck: check( - "organizations_status_check", - sql`${table.status} IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')`, - ), - }), -); - -/** Better Auth's default human identity model and table. */ -export const user = pgTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").notNull().default(false), - image: text("image"), - defaultOrganizationId: uuid("default_organization_id").references( - () => organizations.id, - { onDelete: "set null" }, - ), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), -}); - -/** - * Provider-neutral prices loaded from deployment configuration and verified - * against the provider catalog. Price entries are immutable; a price change - * creates a new provider product and entry so existing subscriptions remain - * grandfathered. - */ -export const billingPriceEntries = pgTable( - "billing_price_entries", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - catalogKey: text("catalog_key").notNull(), - plan: text("plan").notNull(), - billingInterval: text("billing_interval").notNull(), - currency: text("currency").notNull(), - amountMinor: integer("amount_minor").notNull(), - provider: text("provider").notNull(), - providerProductId: text("provider_product_id").notNull(), - verifiedAt: timestamp("verified_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - providerProductUnique: uniqueIndex( - "billing_price_entries_provider_product_uidx", - ).on(table.provider, table.providerProductId), - catalogKeyIdx: index("billing_price_entries_catalog_key_idx").on( - table.catalogKey, - ), - amountCheck: check( - "billing_price_entries_amount_check", - sql`${table.amountMinor} > 0`, - ), - currencyCheck: check( - "billing_price_entries_currency_check", - sql`${table.currency} ~ '^[A-Z]{3}$'`, - ), - planCheck: check( - "billing_price_entries_plan_check", - sql`${table.plan} IN ('pro', 'business')`, - ), - intervalCheck: check( - "billing_price_entries_interval_check", - sql`${table.billingInterval} IN ('month', 'year')`, - ), - }), -); - -export const billingCatalogRevisions = pgTable( - "billing_catalog_revisions", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - revision: integer("revision").notNull().unique(), - checkoutProvider: text("checkout_provider").notNull(), - status: text("status").notNull().default("pending_verification"), - verifiedAt: timestamp("verified_at", { withTimezone: true }), - activatedAt: timestamp("activated_at", { withTimezone: true }), - retiredAt: timestamp("retired_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - statusCheck: check( - "billing_catalog_revisions_status_check", - sql`${table.status} IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')`, - ), - revisionCheck: check( - "billing_catalog_revisions_revision_check", - sql`${table.revision} > 0`, - ), - activeProviderUnique: uniqueIndex( - "billing_catalog_revisions_active_provider_uidx", - ) - .on(table.checkoutProvider) - .where(sql`${table.status} = 'active'`), - }), -); - -export const billingCatalogRevisionItems = pgTable( - "billing_catalog_revision_items", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - catalogRevisionId: uuid("catalog_revision_id") - .notNull() - .references(() => billingCatalogRevisions.id, { - onDelete: "cascade", - }), - catalogKey: text("catalog_key").notNull(), - billingPriceEntryId: uuid("billing_price_entry_id") - .notNull() - .references(() => billingPriceEntries.id, { - onDelete: "restrict", - }), - }, - (table) => ({ - revisionKeyUnique: uniqueIndex( - "billing_catalog_revision_items_revision_key_uidx", - ).on(table.catalogRevisionId, table.catalogKey), - revisionPriceUnique: uniqueIndex( - "billing_catalog_revision_items_revision_price_uidx", - ).on(table.catalogRevisionId, table.billingPriceEntryId), - }), -); - -/** One provider customer per authenticated payer and provider. */ -export const billingProviderCustomers = pgTable( - "billing_provider_customers", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - provider: text("provider").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - providerCustomerId: text("provider_customer_id"), - idempotencyKey: text("idempotency_key").notNull(), - status: text("status").notNull().default("creating"), - lastError: text("last_error"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - providerUserUnique: uniqueIndex( - "billing_provider_customers_provider_user_uidx", - ).on(table.provider, table.userId), - providerCustomerUnique: uniqueIndex( - "billing_provider_customers_provider_customer_uidx", - ) - .on(table.provider, table.providerCustomerId) - .where(sql`${table.providerCustomerId} IS NOT NULL`), - idempotencyUnique: uniqueIndex( - "billing_provider_customers_idempotency_uidx", - ).on(table.idempotencyKey), - statusCheck: check( - "billing_provider_customers_status_check", - sql`${table.status} IN ('creating', 'active', 'conflicted')`, - ), - }), -); - -/** A durable checkout/subscription correlation state machine. */ -export const billingCheckoutAttempts = pgTable( - "billing_checkout_attempts", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - attemptId: text("attempt_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("bca")), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - payerUserId: text("payer_user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - provider: text("provider").notNull(), - catalogRevision: integer("catalog_revision").notNull(), - catalogKey: text("catalog_key").notNull(), - requestedPlan: text("requested_plan").notNull(), - requestedInterval: text("requested_interval").notNull(), - pendingTeamName: text("pending_team_name"), - billingPriceEntryId: uuid("billing_price_entry_id") - .notNull() - .references(() => billingPriceEntries.id, { onDelete: "restrict" }), - quotedAmountMinor: integer("quoted_amount_minor").notNull(), - quotedCurrency: text("quoted_currency").notNull(), - billingCustomerId: uuid("billing_customer_id").references( - () => billingProviderCustomers.id, - { onDelete: "restrict" }, - ), - providerCheckoutSessionId: text("provider_checkout_session_id"), - checkoutUrlEncrypted: text("checkout_url_encrypted"), - idempotencyKey: text("idempotency_key").notNull(), - status: text("status").notNull().default("creating"), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - lastError: text("last_error"), - completedAt: timestamp("completed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - providerSessionUnique: uniqueIndex( - "billing_checkout_attempts_provider_session_uidx", - ) - .on(table.provider, table.providerCheckoutSessionId) - .where(sql`${table.providerCheckoutSessionId} IS NOT NULL`), - idempotencyUnique: uniqueIndex( - "billing_checkout_attempts_idempotency_uidx", - ).on(table.idempotencyKey), - organizationNonterminalUnique: uniqueIndex( - "billing_checkout_attempts_organization_nonterminal_uidx", - ) - .on(table.organizationId) - .where(sql`${table.status} IN ('creating', 'open')`), - statusCheck: check( - "billing_checkout_attempts_status_check", - sql`${table.status} IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')`, - ), - amountCheck: check( - "billing_checkout_attempts_amount_check", - sql`${table.quotedAmountMinor} > 0`, - ), - }), -); - -/** Durable, idempotent mutation record for changing an existing subscription. - * Entitlements are not projected from this row; the verified provider - * subscription snapshot remains authoritative. */ -export const billingPlanChangeAttempts = pgTable( - "billing_plan_change_attempts", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - changeId: text("change_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("bpc")), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - subscriptionId: uuid("subscription_id") - .notNull() - .references(() => organizationSubscriptions.id, { - onDelete: "restrict", - }), - actorUserId: text("actor_user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - provider: text("provider").notNull(), - idempotencyKey: text("idempotency_key").notNull(), - currentCatalogRevision: integer("current_catalog_revision").notNull(), - currentBillingPriceEntryId: uuid("current_billing_price_entry_id") - .notNull() - .references(() => billingPriceEntries.id, { onDelete: "restrict" }), - currentPlan: text("current_plan").notNull(), - currentInterval: text("current_interval").notNull(), - targetCatalogRevision: integer("target_catalog_revision").notNull(), - targetBillingPriceEntryId: uuid("target_billing_price_entry_id") - .notNull() - .references(() => billingPriceEntries.id, { onDelete: "restrict" }), - targetPlan: text("target_plan").notNull(), - targetInterval: text("target_interval").notNull(), - effectiveAt: text("effective_at").notNull(), - prorationMode: text("proration_mode").notNull(), - providerPaymentId: text("provider_payment_id"), - paymentUrlEncrypted: text("payment_url_encrypted"), - status: text("status").notNull().default("creating"), - lastError: text("last_error"), - requestedAt: timestamp("requested_at", { withTimezone: true }) - .notNull() - .defaultNow(), - completedAt: timestamp("completed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - idempotencyUnique: uniqueIndex( - "billing_plan_change_attempts_idempotency_uidx", - ).on(table.idempotencyKey), - organizationNonterminalUnique: uniqueIndex( - "billing_plan_change_attempts_organization_nonterminal_uidx", - ) - .on(table.organizationId) - .where(sql`${table.status} IN ('creating', 'pending')`), - statusCheck: check( - "billing_plan_change_attempts_status_check", - sql`${table.status} IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')`, - ), - effectiveAtCheck: check( - "billing_plan_change_attempts_effective_at_check", - sql`${table.effectiveAt} IN ('immediately', 'next_billing_date')`, - ), - prorationModeCheck: check( - "billing_plan_change_attempts_proration_mode_check", - sql`${table.prorationMode} IN ('prorated_immediately', 'do_not_bill')`, - ), - currentPlanCheck: check( - "billing_plan_change_attempts_current_plan_check", - sql`${table.currentPlan} IN ('pro', 'business')`, - ), - targetPlanCheck: check( - "billing_plan_change_attempts_target_plan_check", - sql`${table.targetPlan} IN ('pro', 'business')`, - ), - currentIntervalCheck: check( - "billing_plan_change_attempts_current_interval_check", - sql`${table.currentInterval} IN ('month', 'year')`, - ), - targetIntervalCheck: check( - "billing_plan_change_attempts_target_interval_check", - sql`${table.targetInterval} IN ('month', 'year')`, - ), - revisionCheck: check( - "billing_plan_change_attempts_revision_check", - sql`${table.currentCatalogRevision} > 0 AND ${table.targetCatalogRevision} > 0`, - ), - }), -); - -export const billingTrialClaims = pgTable( - "billing_trial_claims", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - verifiedEmailFingerprint: text("verified_email_fingerprint").notNull(), - fingerprintKeyVersion: text("fingerprint_key_version").notNull(), - trialKey: text("trial_key").notNull(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - checkoutAttemptId: uuid("checkout_attempt_id").references( - () => billingCheckoutAttempts.id, - { onDelete: "restrict" }, - ), - status: text("status").notNull().default("reserved"), - expiresAt: timestamp("expires_at", { withTimezone: true }), - redeemedAt: timestamp("redeemed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - userTrialUnique: uniqueIndex("billing_trial_claims_user_trial_uidx") - .on(table.userId, table.trialKey) - .where(sql`${table.status} <> 'released'`), - emailTrialUnique: uniqueIndex("billing_trial_claims_email_trial_uidx") - .on(table.verifiedEmailFingerprint, table.trialKey) - .where(sql`${table.status} <> 'released'`), - statusCheck: check( - "billing_trial_claims_status_check", - sql`${table.status} IN ('reserved', 'redeemed', 'released')`, - ), - }), -); - -/** Historical subscription identity; organization_plan_states is only its projection. */ -export const organizationSubscriptions = pgTable( - "organization_subscriptions", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - billingCustomerId: uuid("billing_customer_id") - .notNull() - .references(() => billingProviderCustomers.id, { - onDelete: "restrict", - }), - billingManagerUserId: text("billing_manager_user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - provider: text("provider").notNull(), - providerSubscriptionId: text("provider_subscription_id").notNull(), - providerProductId: text("provider_product_id").notNull(), - billingPriceEntryId: uuid("billing_price_entry_id") - .notNull() - .references(() => billingPriceEntries.id, { onDelete: "restrict" }), - catalogKey: text("catalog_key").notNull(), - plan: text("plan").notNull(), - billingInterval: text("billing_interval").notNull(), - status: text("status").notNull().default("pending"), - currentPeriodStartsAt: timestamp("current_period_starts_at", { - withTimezone: true, - }), - currentPeriodEndsAt: timestamp("current_period_ends_at", { - withTimezone: true, - }), - paidThroughAt: timestamp("paid_through_at", { withTimezone: true }), - trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }), - pastDueAt: timestamp("past_due_at", { withTimezone: true }), - graceEndsAt: timestamp("grace_ends_at", { withTimezone: true }), - cancelAtPeriodEnd: boolean("cancel_at_period_end") - .notNull() - .default(false), - isEntitlementSource: boolean("is_entitlement_source") - .notNull() - .default(false), - lastProviderEventAt: timestamp("last_provider_event_at", { - withTimezone: true, - }), - lastReconciledAt: timestamp("last_reconciled_at", { - withTimezone: true, - }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - providerSubscriptionUnique: uniqueIndex( - "organization_subscriptions_provider_subscription_uidx", - ).on(table.provider, table.providerSubscriptionId), - organizationSourceUnique: uniqueIndex( - "organization_subscriptions_organization_source_uidx", - ) - .on(table.organizationId) - .where(sql`${table.isEntitlementSource} = true`), - statusCheck: check( - "organization_subscriptions_status_check", - sql`${table.status} IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')`, - ), - planCheck: check( - "organization_subscriptions_plan_check", - sql`${table.plan} IN ('pro', 'business')`, - ), - intervalCheck: check( - "organization_subscriptions_interval_check", - sql`${table.billingInterval} IN ('month', 'year')`, - ), - }), -); - -export const organizationPlanStates = pgTable( - "organization_plan_states", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .unique() - .references(() => organizations.id, { onDelete: "restrict" }), - plan: text("plan").notNull().default("free"), - activeSubscriptionId: uuid("active_subscription_id").references( - () => organizationSubscriptions.id, - { onDelete: "restrict" }, - ), - teamsLimitOverride: integer("teams_limit_override"), - contactsLimitOverride: integer("contacts_limit_override"), - projectionVersion: integer("projection_version").notNull().default(0), - firstPaidActivatedAt: timestamp("first_paid_activated_at", { - withTimezone: true, - }), - rampStage: integer("ramp_stage").notNull().default(0), - rampCleanStageDays: integer("ramp_clean_stage_days") - .notNull() - .default(0), - rampEvaluatedAt: timestamp("ramp_evaluated_at", { - withTimezone: true, - }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - planCheck: check( - "organization_plan_states_plan_check", - sql`${table.plan} IN ('free', 'pro', 'business')`, - ), - teamsOverrideCheck: check( - "organization_plan_states_teams_override_check", - sql`${table.teamsLimitOverride} IS NULL OR ${table.teamsLimitOverride} > 0`, - ), - contactsOverrideCheck: check( - "organization_plan_states_contacts_override_check", - sql`${table.contactsLimitOverride} IS NULL OR ${table.contactsLimitOverride} > 0`, - ), - rampStageCheck: check( - "organization_plan_states_ramp_stage_check", - sql`${table.rampStage} BETWEEN 0 AND 3`, - ), - rampCleanDaysCheck: check( - "organization_plan_states_ramp_clean_days_check", - sql`${table.rampCleanStageDays} >= 0`, - ), - }), -); - -export const billingWebhookEvents = pgTable( - "billing_webhook_events", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - provider: text("provider").notNull(), - providerEventId: text("provider_event_id").notNull(), - eventType: text("event_type").notNull(), - occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(), - payloadEncrypted: text("payload_encrypted"), - payloadKeyVersion: text("payload_key_version"), - status: text("status").notNull().default("pending"), - processingAttempts: integer("processing_attempts").notNull().default(0), - lastError: text("last_error"), - availableAt: timestamp("available_at", { withTimezone: true }) - .notNull() - .defaultNow(), - lockedAt: timestamp("locked_at", { withTimezone: true }), - leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), - workerId: text("worker_id"), - receivedAt: timestamp("received_at", { withTimezone: true }) - .notNull() - .defaultNow(), - processedAt: timestamp("processed_at", { withTimezone: true }), - }, - (table) => ({ - providerEventUnique: uniqueIndex( - "billing_webhook_events_provider_event_uidx", - ).on(table.provider, table.providerEventId), - queueIdx: index("billing_webhook_events_queue_idx").on( - table.status, - table.availableAt, - ), - statusCheck: check( - "billing_webhook_events_status_check", - sql`${table.status} IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')`, - ), - }), -); - -export const planSendUsageBuckets = pgTable( - "plan_send_usage_buckets", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - bucketMonth: timestamp("bucket_month", { - withTimezone: true, - }).notNull(), - committed: integer("committed").notNull().default(0), - reserved: integer("reserved").notNull().default(0), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - organizationMonthUnique: uniqueIndex( - "plan_send_usage_buckets_organization_month_uidx", - ).on(table.organizationId, table.bucketMonth), - countCheck: check( - "plan_send_usage_buckets_count_check", - sql`${table.committed} >= 0 AND ${table.reserved} >= 0`, - ), - }), -); - -export const planSendReservations = pgTable( - "plan_send_reservations", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - outboundMessageId: uuid("outbound_message_id").notNull(), - bucketId: uuid("bucket_id") - .notNull() - .references(() => planSendUsageBuckets.id, { - onDelete: "restrict", - }), - amount: integer("amount").notNull().default(1), - state: text("state").notNull().default("reserved"), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - committedAt: timestamp("committed_at", { withTimezone: true }), - releasedAt: timestamp("released_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - outboundUnique: uniqueIndex("plan_send_reservations_outbound_uidx").on( - table.outboundMessageId, - ), - expiryIdx: index("plan_send_reservations_expiry_idx").on( - table.state, - table.expiresAt, - ), - amountCheck: check( - "plan_send_reservations_amount_check", - sql`${table.amount} > 0`, - ), - stateCheck: check( - "plan_send_reservations_state_check", - sql`${table.state} IN ('reserved', 'committed', 'released')`, - ), - }), -); - -export const teamSendingControls = pgTable( - "team_sending_controls", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .unique() - .references(() => teams.id, { onDelete: "restrict" }), - status: text("status").notNull().default("normal"), - reasonCode: text("reason_code"), - source: text("source").notNull().default("automatic"), - enteredAt: timestamp("entered_at", { withTimezone: true }), - evaluatedAt: timestamp("evaluated_at", { withTimezone: true }), - minimumHoldUntil: timestamp("minimum_hold_until", { - withTimezone: true, - }), - operatorUserId: text("operator_user_id").references(() => user.id, { - onDelete: "restrict", - }), - operatorReason: text("operator_reason"), - overriddenAt: timestamp("overridden_at", { withTimezone: true }), - cleanEvaluationDays: integer("clean_evaluation_days") - .notNull() - .default(0), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - statusCheck: check( - "team_sending_controls_status_check", - sql`${table.status} IN ('normal', 'warned', 'marketing_paused', 'all_paused')`, - ), - sourceCheck: check( - "team_sending_controls_source_check", - sql`${table.source} IN ('automatic', 'operator')`, - ), - cleanDaysCheck: check( - "team_sending_controls_clean_days_check", - sql`${table.cleanEvaluationDays} >= 0`, - ), - }), -); - -export const sendingDomains = pgTable( - "sending_domains", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - domainId: text("domain_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("domain")), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - domain: text("domain").notNull(), - challengeTokenHash: text("challenge_token_hash").notNull(), - status: text("status").notNull().default("pending"), - verifiedAt: timestamp("verified_at", { withTimezone: true }), - lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }), - nextCheckAt: timestamp("next_check_at", { withTimezone: true }), - failedCheckCount: integer("failed_check_count").notNull().default(0), - firstFailedAt: timestamp("first_failed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - domainIdCheck: publicIdCheck( - "sending_domains_domain_id_check", - table.domainId, - "domain", - ), - organizationDomainUnique: uniqueIndex( - "sending_domains_organization_domain_uidx", - ).on(table.organizationId, table.domain), - statusCheck: check( - "sending_domains_status_check", - sql`${table.status} IN ('pending', 'verified', 'revoked', 'failed')`, - ), - failedCheckCountCheck: check( - "sending_domains_failed_check_count_check", - sql`${table.failedCheckCount} >= 0`, - ), - }), -); - -/** Explicit organization authorization; authentication alone grants nothing. */ -export const organizationMembers = pgTable( - "organization_members", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "cascade" }), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - role: text("role").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - organizationUserIdx: uniqueIndex( - "organization_members_organization_id_user_id_idx", - ).on(table.organizationId, table.userId), - roleCheck: check( - "organization_members_role_check", - sql`${table.role} IN ('owner', 'admin', 'member')`, - ), - }), -); - -/** Immutable operational record for organization administration and - * integration-driven lifecycle changes. It intentionally stores references - * rather than secrets or raw API keys. */ -export const organizationAuditEvents = pgTable( - "organization_audit_events", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - actorType: text("actor_type").notNull(), // user|organization_key|team_key|system - actorId: text("actor_id"), - action: text("action").notNull(), - teamId: uuid("team_id"), - espConfigId: uuid("esp_config_id"), - espGrantId: uuid("esp_grant_id"), - metadata: jsonb("metadata").notNull().default({}), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - organizationCreatedIdx: index( - "organization_audit_events_organization_id_created_at_idx", - ).on(table.organizationId, table.createdAt), - teamCreatedIdx: index( - "organization_audit_events_team_id_created_at_idx", - ).on(table.teamId, table.createdAt), - }), -); - -/** Team/workspace and email-data boundary. Every team belongs to one org. */ -export const teams = pgTable( - "teams", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: text("team_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("team")), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - externalId: text("external_id"), - provisioningRequestHash: text("provisioning_request_hash"), - name: text("name").notNull(), - status: text("status").notNull().default("active"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - teamIdCheck: publicIdCheck("teams_team_id_check", table.teamId, "team"), - organizationExternalIdIdx: uniqueIndex( - "teams_organization_id_external_id_idx", - ) - .on(table.organizationId, table.externalId) - .where(sql`${table.externalId} IS NOT NULL`), - idOrganizationIdx: unique("teams_id_organization_id_unique").on( - table.id, - table.organizationId, - ), - statusCheck: check( - "teams_status_check", - sql`${table.status} IN ('active', 'sending_suspended', 'archived')`, - ), - }), -); - -/** Team membership is independent from organization membership. */ -export const teamMembers = pgTable( - "team_members", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - role: text("role").notNull().default("member"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - teamUserIdx: uniqueIndex("team_members_team_id_user_id_idx").on( - table.teamId, - table.userId, - ), - roleCheck: check( - "team_members_role_check", - sql`${table.role} IN ('admin', 'member')`, - ), - }), -); - -/** Better Auth's remaining default core models/tables. */ -export const session = pgTable( - "session", - { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - }, - (table) => ({ - userIdIdx: index("auth_session_user_id_idx").on(table.userId), - }), -); - -export const account = pgTable( - "account", - { - id: text("id").primaryKey(), - // Better Auth 1.7 account key is (issuer, accountId). - issuer: text("issuer").notNull(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at", { - withTimezone: true, - }), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { - withTimezone: true, - }), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - }, - (table) => ({ - userIdIdx: index("auth_account_user_id_idx").on(table.userId), - issuerAccountIdx: uniqueIndex("auth_account_issuer_account_id_uidx").on( - table.issuer, - table.accountId, - ), - }), -); - -export const verification = pgTable( - "verification", - { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - }, - (table) => ({ - identifierIdx: index("auth_verification_identifier_idx").on( - table.identifier, - ), - }), -); - -export const jwks = pgTable("jwks", { - id: text("id").primaryKey(), - publicKey: text("public_key").notNull(), - privateKey: text("private_key").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }), - // Required by Better Auth's JWT plugin to select a signing key for a - // configured algorithm/curve. Existing keys inherit the default - // algorithm when these nullable fields are absent. - alg: text("alg"), - crv: text("crv"), -}); - -export const oauthClient = pgTable( - "oauth_client", - { - id: text("id").primaryKey(), - clientId: text("client_id").notNull().unique(), - clientSecret: text("client_secret"), - // Required for CIMD ownership and refresh. Discovery-owned clients - // must not be mutable through managed-client paths. - clientDiscoveryId: text("client_discovery_id"), - disabled: boolean("disabled").default(false), - skipConsent: boolean("skip_consent"), - enableEndSession: boolean("enable_end_session"), - subjectType: text("subject_type"), - scopes: text("scopes").array(), - clientCredentialsScopes: text("client_credentials_scopes") - .array() - .notNull() - .default([]), - userId: text("user_id").references(() => user.id, { - onDelete: "cascade", - }), - createdAt: timestamp("created_at", { withTimezone: true }), - updatedAt: timestamp("updated_at", { withTimezone: true }), - name: text("name"), - uri: text("uri"), - icon: text("icon"), - contacts: text("contacts").array(), - tos: text("tos"), - policy: text("policy"), - softwareId: text("software_id"), - softwareVersion: text("software_version"), - softwareStatement: text("software_statement"), - redirectUris: text("redirect_uris").array().notNull(), - postLogoutRedirectUris: text("post_logout_redirect_uris").array(), - backchannelLogoutUri: text("backchannel_logout_uri"), - backchannelLogoutSessionRequired: boolean( - "backchannel_logout_session_required", - ), - tokenEndpointAuthMethod: text("token_endpoint_auth_method"), - applicationType: text("application_type"), - jwks: text("jwks"), - jwksUri: text("jwks_uri"), - grantTypes: text("grant_types").array(), - responseTypes: text("response_types").array(), - public: boolean("public"), - type: text("type"), - requirePKCE: boolean("require_pkce"), - dpopBoundAccessTokens: boolean("dpop_bound_access_tokens").default( - false, - ), - referenceId: text("reference_id"), - metadata: jsonb("metadata"), - }, - (table) => ({ - userIdIdx: index("auth_oauth_client_user_id_idx").on(table.userId), - }), -); - -/** Better Auth OAuth Provider's persistent protected-resource registry. - * CIMD authorization uses this to bind the MCP resource indicator to its - * allowed scopes and token policy. */ -export const oauthResource = pgTable("oauth_resource", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull().unique(), - name: text("name").notNull(), - accessTokenTtl: integer("access_token_ttl"), - refreshTokenTtl: integer("refresh_token_ttl"), - signingAlgorithm: text("signing_algorithm"), - signingKeyId: text("signing_key_id"), - allowedScopes: text("allowed_scopes").array(), - customClaims: jsonb("custom_claims"), - dpopBoundAccessTokensRequired: boolean("dpop_bound_access_tokens_required") - .notNull() - .default(false), - disabled: boolean("disabled").notNull().default(false), - createdAt: timestamp("created_at", { withTimezone: true }), - updatedAt: timestamp("updated_at", { withTimezone: true }), - policyVersion: integer("policy_version").notNull().default(1), - metadata: jsonb("metadata"), -}); - -/** Optional per-client resource linkage used by Better Auth's OAuth provider. - * The provider keeps this table even when resource enforcement is currently - * permissive, so future policy tightening needs no schema rewrite. */ -export const oauthClientResource = pgTable( - "oauth_client_resource", - { - id: text("id").primaryKey(), - clientId: text("client_id") - .notNull() - .references(() => oauthClient.clientId, { onDelete: "cascade" }), - resourceId: text("resource_id") - .notNull() - .references(() => oauthResource.identifier, { - onDelete: "cascade", - }), - metadata: jsonb("metadata"), - createdAt: timestamp("created_at", { withTimezone: true }), - }, - (table) => ({ - clientIdIdx: index("auth_oauth_client_resource_client_id_idx").on( - table.clientId, - ), - resourceIdIdx: index("auth_oauth_client_resource_resource_id_idx").on( - table.resourceId, - ), - clientResourceUnique: uniqueIndex( - "auth_oauth_client_resource_client_id_resource_id_idx", - ).on(table.clientId, table.resourceId), - }), -); - -export const oauthRefreshToken = pgTable( - "oauth_refresh_token", - { - id: text("id").primaryKey(), - token: text("token").notNull().unique(), - clientId: text("client_id") - .notNull() - .references(() => oauthClient.clientId, { - onDelete: "cascade", - }), - sessionId: text("session_id").references(() => session.id, { - onDelete: "set null", - }), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - referenceId: text("reference_id"), - authorizationCodeId: text("authorization_code_id"), - resources: text("resources").array(), - requestedUserInfoClaims: text("requested_user_info_claims").array(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - revoked: timestamp("revoked", { withTimezone: true }), - rotatedAt: timestamp("rotated_at", { withTimezone: true }), - rotationReplayResponse: text("rotation_replay_response"), - rotationReplayExpiresAt: timestamp("rotation_replay_expires_at", { - withTimezone: true, - }), - authTime: timestamp("auth_time", { withTimezone: true }), - confirmation: jsonb("confirmation"), - scopes: text("scopes").array().notNull(), - }, - (table) => ({ - clientIdIdx: index("auth_oauth_refresh_token_client_id_idx").on( - table.clientId, - ), - authorizationCodeIdIdx: index( - "auth_oauth_refresh_token_authorization_code_id_idx", - ).on(table.authorizationCodeId), - sessionIdIdx: index("auth_oauth_refresh_token_session_id_idx").on( - table.sessionId, - ), - userIdIdx: index("auth_oauth_refresh_token_user_id_idx").on( - table.userId, - ), - }), -); - -export const oauthAccessToken = pgTable( - "oauth_access_token", - { - id: text("id").primaryKey(), - token: text("token").notNull().unique(), - clientId: text("client_id") - .notNull() - .references(() => oauthClient.clientId, { - onDelete: "cascade", - }), - sessionId: text("session_id").references(() => session.id, { - onDelete: "set null", - }), - userId: text("user_id").references(() => user.id, { - onDelete: "cascade", - }), - referenceId: text("reference_id"), - authorizationCodeId: text("authorization_code_id"), - resources: text("resources").array(), - requestedUserInfoClaims: text("requested_user_info_claims").array(), - refreshId: text("refresh_id").references(() => oauthRefreshToken.id, { - onDelete: "set null", - }), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - scopes: text("scopes").array().notNull(), - confirmation: jsonb("confirmation"), - }, - (table) => ({ - clientIdIdx: index("auth_oauth_access_token_client_id_idx").on( - table.clientId, - ), - sessionIdIdx: index("auth_oauth_access_token_session_id_idx").on( - table.sessionId, - ), - userIdIdx: index("auth_oauth_access_token_user_id_idx").on( - table.userId, - ), - authorizationCodeIdIdx: index( - "auth_oauth_access_token_authorization_code_id_idx", - ).on(table.authorizationCodeId), - refreshIdIdx: index("auth_oauth_access_token_refresh_id_idx").on( - table.refreshId, - ), - }), -); - -export const oauthConsent = pgTable( - "oauth_consent", - { - id: text("id").primaryKey(), - clientId: text("client_id") - .notNull() - .references(() => oauthClient.clientId, { - onDelete: "cascade", - }), - userId: text("user_id").references(() => user.id, { - onDelete: "cascade", - }), - referenceId: text("reference_id"), - resources: text("resources").array(), - requestedUserInfoClaims: text("requested_user_info_claims").array(), - scopes: text("scopes").array().notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - }, - (table) => ({ - clientIdIdx: index("auth_oauth_consent_client_id_idx").on( - table.clientId, - ), - userIdIdx: index("auth_oauth_consent_user_id_idx").on(table.userId), - }), -); - -/** Single-use identifiers for private_key_jwt client assertions. The local - * MCP client is public and does not use these, but keeping the provider's - * complete schema makes the configured plugin safe to extend. */ -export const oauthClientAssertion = pgTable("oauth_client_assertion", { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), -}); - -/** The team an OAuth end-user picked on the post-login "select a team" screen - * (`/oauth/select-team`, shown only when their account belongs to more than - * one team — mirrors Notion's workspace picker). One row per Better Auth - * session, written when the user submits their choice and read back by - * `oauthProvider`'s `postLogin.consentReferenceId` hook (see - * `auth/better-auth.ts`), which threads it through as the OAuth `referenceId` - * so it ends up on the minted access token's `team_id` claim - * (`customAccessTokenClaims`). Without this, a generic OAuth/MCP client has no - * way to tell SendLit which team to scope its requests to — there is no - * standard OAuth mechanism for it, and the custom `X-Sendlit-Team-Id` header - * only works for clients SendLit itself controls (the web dashboard). */ -export const oauthPostLoginTeamSelections = pgTable( - "oauth_post_login_team_selections", - { - sessionId: text("session_id") - .primaryKey() - .references(() => session.id, { onDelete: "cascade" }), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, -); - -/** Organization keys provision/manage resources only inside one organization. */ -export const organizationApiKeys = pgTable( - "organization_api_keys", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationApiKeyId: text("organization_api_key_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("oak")), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "cascade" }), - name: text("name").notNull(), - keyHash: text("key_hash").notNull().unique(), - keyPrefix: text("key_prefix").notNull(), - scopes: text("scopes").array().notNull().default([]), - expiresAt: timestamp("expires_at", { withTimezone: true }), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - createdByUserId: text("created_by_user_id").references(() => user.id, { - onDelete: "set null", - }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - organizationApiKeyIdCheck: publicIdCheck( - "organization_api_keys_public_id_check", - table.organizationApiKeyId, - "oak", - ), - organizationIdx: index("organization_api_keys_organization_id_idx").on( - table.organizationId, - ), - }), -); - -/** A team key authenticates as exactly one team and never as a user/org. */ -export const teamApiKeys = pgTable( - "team_api_keys", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamApiKeyId: text("team_api_key_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("tak")), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - keyHash: text("key_hash").notNull().unique(), - keyPrefix: text("key_prefix").notNull(), - name: text("name").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - createdByType: text("created_by_type").notNull().default("user"), - createdById: text("created_by_id"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - teamApiKeyIdCheck: publicIdCheck( - "team_api_keys_public_id_check", - table.teamApiKeyId, - "tak", - ), - teamIdx: index("team_api_keys_team_id_idx").on(table.teamId), - createdByTypeCheck: check( - "team_api_keys_created_by_type_check", - sql`${table.createdByType} IN ('user', 'organization_key', 'system')`, - ), - }), -); - -/** A contact is a recipient/subscriber. Equivalent of CourseLit's `User` model, - * stripped of everything course/product related. `contactId` is the public - * handle (`cnt_...`); `id` is internal-only. */ -export const contacts = pgTable( - "contacts", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - contactId: text("contact_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("cnt")), - email: text("email").notNull(), - name: text("name"), - subscribed: boolean("subscribed").notNull().default(true), - // Intentionally kept alongside `contact_custom_field_values`: this - // jsonb is the denormalized public read/render snapshot (API - // responses, merge tags), while the table is the indexed store used - // for segmentation queries. - customFields: jsonb("custom_fields") - .$type() - .notNull() - .default({}), - tags: text("tags").array().notNull().default([]), - unsubscribeToken: text("unsubscribe_token").notNull().unique(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - teamEmailIdx: uniqueIndex("contacts_team_id_email_idx").on( - table.teamId, - table.email, - ), - contactIdCheck: publicIdCheck( - "contacts_contact_id_check", - table.contactId, - "cnt", - ), - }), -); - -/** Indexed custom field values for scalable generic contact segmentation. - * `contacts.customFields` remains the public/read snapshot; this table stores - * one row per scalar value, including each element of scalar arrays. - * `contactId` here references the contact's *internal* id. */ -export const contactCustomFieldValues = pgTable( - "contact_custom_field_values", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - contactId: uuid("contact_id") - .notNull() - .references(() => contacts.id, { onDelete: "cascade" }), - key: text("key").notNull(), - valueType: text("value_type").notNull(), // string | number | boolean | date - valueText: text("value_text"), - valueNumber: doublePrecision("value_number"), - valueBoolean: boolean("value_boolean"), - valueDate: timestamp("value_date", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - contactKeyIdx: index("contact_custom_field_values_contact_key_idx").on( - table.teamId, - table.contactId, - table.key, - ), - textLookupIdx: index("contact_custom_field_values_text_lookup_idx").on( - table.teamId, - table.key, - table.valueText, - ), - numberLookupIdx: index( - "contact_custom_field_values_number_lookup_idx", - ).on(table.teamId, table.key, table.valueNumber), - booleanLookupIdx: index( - "contact_custom_field_values_boolean_lookup_idx", - ).on(table.teamId, table.key, table.valueBoolean), - dateLookupIdx: index("contact_custom_field_values_date_lookup_idx").on( - table.teamId, - table.key, - table.valueDate, - ), - }), -); - -export const emailTemplates = pgTable( - "email_templates", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - templateId: text("template_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("tpl")), - title: text("title").notNull(), - purpose: text("purpose", { - enum: ["marketing", "transactional"], - }) - .notNull() - .default("marketing"), - // { content: EmailBlock[], style: EmailStyle, meta: EmailMeta } — see @sendlit/email-editor - content: jsonb("content").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - teamTitleIdx: uniqueIndex("email_templates_team_id_title_idx").on( - table.teamId, - table.title, - ), - templateIdCheck: publicIdCheck( - "email_templates_template_id_check", - table.templateId, - "tpl", - ), - purposeCheck: check( - "email_templates_purpose_check", - sql`${table.purpose} in ('marketing', 'transactional')`, - ), - }), -); - -export const media = pgTable( - "media", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - mediaId: text("media_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("med")), - mediaLitId: text("media_lit_id").notNull(), - url: text("url").notNull(), - thumbnailUrl: text("thumbnail_url"), - fileName: text("file_name"), - mimeType: text("mime_type"), - size: integer("size"), - width: integer("width"), - height: integer("height"), - alt: text("alt"), - caption: text("caption"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - mediaIdCheck: publicIdCheck( - "media_media_id_check", - table.mediaId, - "med", - ), - teamMediaLitIdx: uniqueIndex("media_team_id_media_lit_id_idx").on( - table.teamId, - table.mediaLitId, - ), - teamCreatedAtIdx: index("media_team_id_created_at_idx").on( - table.teamId, - table.createdAt, - ), - }), -); - -export const mediaReferences = pgTable( - "media_references", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - mediaId: uuid("media_id") - .notNull() - .references(() => media.id, { onDelete: "cascade" }), - resourceType: text("resource_type").notNull(), - resourceInternalId: uuid("resource_internal_id").notNull(), - resourcePublicId: text("resource_public_id").notNull(), - parentResourceInternalId: uuid("parent_resource_internal_id"), - parentResourcePublicId: text("parent_resource_public_id"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - resourceIdx: index("media_references_resource_idx").on( - table.teamId, - table.resourceType, - table.resourceInternalId, - ), - mediaIdx: index("media_references_media_id_idx").on(table.mediaId), - uniqueResourceMediaIdx: uniqueIndex( - "media_references_resource_media_idx", - ).on( - table.teamId, - table.resourceType, - table.resourceInternalId, - table.mediaId, - ), - }), -); - -/** A saved, named, reusable contact filter — the persisted form of the - * `ContactFilterWithAggregator` shape (see `contacts/segment.ts`) that - * `sequences.filter`/`excludeFilter` already store inline per-broadcast. This - * table lets a team build a filter once and reuse it by name instead of - * re-building it for every broadcast/sequence. */ -export const segments = pgTable( - "segments", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - segmentId: text("segment_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("seg")), - name: text("name").notNull(), - // ContactFilterWithAggregator — see contacts/segment.ts - filter: jsonb("filter").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - teamNameIdx: uniqueIndex("segments_team_id_name_idx").on( - table.teamId, - table.name, - ), - segmentIdCheck: publicIdCheck( - "segments_segment_id_check", - table.segmentId, - "seg", - ), - }), -); - -/** One immutable ownership model for organization- and team-owned ESPs. */ -export const espConfigs = pgTable( - "esp_configs", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - espId: text("esp_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("esp")), - ownerScope: text("owner_scope").notNull(), - organizationId: uuid("organization_id").references( - () => organizations.id, - { onDelete: "restrict" }, - ), - teamId: uuid("team_id").references(() => teams.id, { - onDelete: "restrict", - }), - name: text("name").notNull(), - provider: text("provider").notNull().default("smtp"), - host: text("host").notNull(), - port: integer("port").notNull().default(587), - secure: boolean("secure").notNull().default(false), - username: text("username"), - encryptedSecret: text("encrypted_secret"), - fromName: text("from_name"), - fromEmail: text("from_email"), - status: text("status").notNull().default("draft"), - secretVersion: integer("secret_version").notNull().default(1), - lastTestedAt: timestamp("last_tested_at", { withTimezone: true }), - lastTestStatus: text("last_test_status"), // success | failed - lastTestError: text("last_test_error"), - activatedAt: timestamp("activated_at", { withTimezone: true }), - drainUntil: timestamp("drain_until", { withTimezone: true }), - retiredAt: timestamp("retired_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - espIdCheck: publicIdCheck( - "esp_configs_esp_id_check", - table.espId, - "esp", - ), - organizationIdx: index("esp_configs_organization_id_idx").on( - table.organizationId, - ), - teamIdx: index("esp_configs_team_id_idx").on(table.teamId), - idOrganizationIdx: unique("esp_configs_id_organization_id_unique").on( - table.id, - table.organizationId, - ), - idTeamIdx: unique("esp_configs_id_team_id_unique").on( - table.id, - table.teamId, - ), - ownerCheck: check( - "esp_configs_owner_check", - sql`(${table.ownerScope} = 'organization' AND ${table.organizationId} IS NOT NULL AND ${table.teamId} IS NULL) - OR (${table.ownerScope} = 'team' AND ${table.organizationId} IS NULL AND ${table.teamId} IS NOT NULL)`, - ), - statusCheck: check( - "esp_configs_status_check", - sql`${table.status} IN ('draft', 'active', 'suspended', 'draining', 'retired')`, - ), - }), -); - -export const organizationDeliveryPolicies = pgTable( - "organization_delivery_policies", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - organizationId: uuid("organization_id") - .notNull() - .unique() - .references(() => organizations.id, { onDelete: "cascade" }), - defaultEspConfigId: uuid("default_esp_config_id"), - autoGrantDefaultEsp: boolean("auto_grant_default_esp") - .notNull() - .default(false), - defaultDailyLimit: integer("default_daily_limit"), - defaultMonthlyLimit: integer("default_monthly_limit"), - aggregateDailyLimit: integer("aggregate_daily_limit"), - aggregateMonthlyLimit: integer("aggregate_monthly_limit"), - teamEspEnabledByDefault: boolean("team_esp_enabled_by_default") - .notNull() - .default(true), - teamCanChangeDefault: boolean("team_can_change_default") - .notNull() - .default(true), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - defaultEspFk: foreignKey({ - name: "organization_delivery_policies_default_esp_fk", - columns: [table.defaultEspConfigId, table.organizationId], - foreignColumns: [espConfigs.id, espConfigs.organizationId], - }).onDelete("restrict"), - limitCheck: check( - "organization_delivery_policies_limit_check", - sql`(${table.defaultDailyLimit} IS NULL OR ${table.defaultDailyLimit} >= 0) - AND (${table.defaultMonthlyLimit} IS NULL OR ${table.defaultMonthlyLimit} >= 0) - AND (${table.aggregateDailyLimit} IS NULL OR ${table.aggregateDailyLimit} >= 0) - AND (${table.aggregateMonthlyLimit} IS NULL OR ${table.aggregateMonthlyLimit} >= 0)`, - ), - }), -); - -export const espConfigTeamGrants = pgTable( - "esp_config_team_grants", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - grantId: text("grant_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("egr")), - organizationId: uuid("organization_id").notNull(), - espConfigId: uuid("esp_config_id").notNull(), - teamId: uuid("team_id").notNull(), - status: text("status").notNull().default("active"), - drainUntil: timestamp("drain_until", { withTimezone: true }), - fromName: text("from_name"), - replyTo: text("reply_to"), - dailyLimit: integer("daily_limit"), - monthlyLimit: integer("monthly_limit"), - createdByType: text("created_by_type").notNull(), - createdById: text("created_by_id"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - grantIdCheck: publicIdCheck( - "esp_config_team_grants_public_id_check", - table.grantId, - "egr", - ), - activeTeamIdx: uniqueIndex( - "esp_config_team_grants_non_revoked_team_idx", - ) - .on(table.teamId) - .where(sql`${table.status} <> 'revoked'`), - idOrganizationUnique: unique( - "esp_config_team_grants_id_organization_id_unique", - ).on(table.id, table.organizationId), - pinUnique: unique("esp_config_team_grants_id_team_esp_unique").on( - table.id, - table.teamId, - table.espConfigId, - ), - teamOrganizationFk: foreignKey({ - name: "esp_config_team_grants_team_organization_fk", - columns: [table.teamId, table.organizationId], - foreignColumns: [teams.id, teams.organizationId], - }).onDelete("restrict"), - espOrganizationFk: foreignKey({ - name: "esp_config_team_grants_esp_organization_fk", - columns: [table.espConfigId, table.organizationId], - foreignColumns: [espConfigs.id, espConfigs.organizationId], - }).onDelete("restrict"), - statusCheck: check( - "esp_config_team_grants_status_check", - sql`${table.status} IN ('active', 'draining', 'suspended', 'revoked')`, - ), - limitCheck: check( - "esp_config_team_grants_limit_check", - sql`(${table.dailyLimit} IS NULL OR ${table.dailyLimit} >= 0) - AND (${table.monthlyLimit} IS NULL OR ${table.monthlyLimit} >= 0)`, - ), - createdByTypeCheck: check( - "esp_config_team_grants_created_by_type_check", - sql`${table.createdByType} IN ('user', 'organization_key', 'system')`, - ), - }), -); - -export const teamDeliverySettings = pgTable( - "team_delivery_settings", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .unique() - .references(() => teams.id, { onDelete: "cascade" }), - teamEspEnabled: boolean("team_esp_enabled").notNull().default(true), - teamCanChangeDefault: boolean("team_can_change_default") - .notNull() - .default(true), - defaultSource: text("default_source"), - defaultTeamEspConfigId: uuid("default_team_esp_config_id"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - defaultTeamEspFk: foreignKey({ - name: "team_delivery_settings_default_team_esp_fk", - columns: [table.defaultTeamEspConfigId, table.teamId], - foreignColumns: [espConfigs.id, espConfigs.teamId], - }).onDelete("restrict"), - defaultSourceCheck: check( - "team_delivery_settings_default_source_check", - sql`${table.defaultSource} IS NULL OR ${table.defaultSource} IN ('organization', 'team')`, - ), - }), -); - -/** Per-team general workspace settings ("settings.general") — a per-team - * singleton like `esp_configs`, addressed via the team (`/settings/general`), - * so no public `_id` is needed. `mailingAddress` is the physical - * postal address rendered in email footers (CAN-SPAM/GDPR requirement). */ -export const settings = pgTable("settings", { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .unique() - .references(() => teams.id, { onDelete: "cascade" }), - mailingAddress: text("mailing_address"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), -}); - -/** A broadcast (one-off, `type = 'broadcast'`) or a sequence (multi-step, - * `type = 'sequence'`) — same shape as CourseLit's `Sequence` model. */ -export const sequences = pgTable( - "sequences", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - sequenceId: text("sequence_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("seq")), - type: text("type").notNull(), // 'broadcast' | 'sequence' - title: text("title").notNull().default(""), - status: text("status").notNull().default("draft"), // draft|active|paused|completed - deliverySourceIntent: jsonb("delivery_source_intent"), - // Resolved and pinned atomically at activation. Drafts may leave these - // null while retaining their public source intent in the API layer. - deliverySourceType: text("delivery_source_type"), // organization | team - outboxId: uuid("outbox_id").references(() => espConfigs.id, { - onDelete: "restrict", - }), - espGrantId: uuid("esp_grant_id").references( - () => espConfigTeamGrants.id, - { onDelete: "restrict" }, - ), - triggerType: text("trigger_type"), // Constants.EventType - triggerData: text("trigger_data"), - // UserFilterWithAggregator — see contacts/segment.ts - filter: jsonb("filter"), - excludeFilter: jsonb("exclude_filter"), - emailsOrder: text("emails_order").array().notNull().default([]), - entrants: text("entrants").array().notNull().default([]), - // { broadcast: { sentAt, lockedAt }, sequence: { subscribers, unsubscribers, failed } } - report: jsonb("report").notNull().default({}), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - sequenceIdCheck: publicIdCheck( - "sequences_sequence_id_check", - table.sequenceId, - "seq", - ), - deliveryPinCheck: check( - "sequences_delivery_pin_check", - sql`( - ${table.deliverySourceType} IS NULL - AND ${table.outboxId} IS NULL - AND ${table.espGrantId} IS NULL - ) OR ( - ${table.deliverySourceType} = 'team' - AND ${table.outboxId} IS NOT NULL - AND ${table.espGrantId} IS NULL - ) OR ( - ${table.deliverySourceType} = 'organization' - AND ${table.outboxId} IS NOT NULL - AND ${table.espGrantId} IS NOT NULL - )`, - ), - }), -); - -/** A structural child of exactly one `sequences` row — never addressed - * independently, so `sequenceId` references the parent's internal `id` - * (unlike the event/log tables below, which store the public id). `emailId` - * is this row's own public handle within that sequence. */ -export const sequenceEmails = pgTable( - "sequence_emails", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - sequenceId: uuid("sequence_id") - .notNull() - .references(() => sequences.id, { onDelete: "cascade" }), - emailId: text("email_id") - .notNull() - .$defaultFn(() => genPublicId("email")), - subject: text("subject").notNull(), - // { content: EmailBlock[], style: EmailStyle, meta: EmailMeta } - content: jsonb("content").notNull(), - delayInMillis: bigint("delay_in_millis", { mode: "number" }) - .notNull() - .default(86400000), - published: boolean("published").notNull().default(false), - templateId: text("template_id"), - actionType: text("action_type"), // tag:add | tag:remove - actionData: jsonb("action_data"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - sequenceEmailIdx: uniqueIndex( - "sequence_emails_sequence_id_email_id_idx", - ).on(table.sequenceId, table.emailId), - emailIdCheck: publicIdCheck( - "sequence_emails_email_id_check", - table.emailId, - "email", - ), - }), -); - -/** A scheduled trigger for a sequence — e.g. "fire DATE_OCCURRED for broadcast X at - * time T", processed by `automation/process-rules.ts`. Not exposed via any - * REST/MCP route today. */ -export const rules = pgTable( - "rules", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - ruleId: text("rule_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("rule")), - event: text("event").notNull(), // Constants.EventType - sequenceId: uuid("sequence_id") - .notNull() - .references(() => sequences.id, { onDelete: "cascade" }), - eventDateInMillis: bigint("event_date_in_millis", { mode: "number" }), - eventData: text("event_data"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - ruleIdCheck: publicIdCheck("rules_rule_id_check", table.ruleId, "rule"), - }), -); - -/** One row per (sequence, contact) currently being delivered. Processed by - * `automation/process-ongoing-sequence.ts`. */ -export const ongoingSequences = pgTable( - "ongoing_sequences", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - sequenceId: uuid("sequence_id") - .notNull() - .references(() => sequences.id, { onDelete: "cascade" }), - contactId: uuid("contact_id") - .notNull() - .references(() => contacts.id, { onDelete: "cascade" }), - nextEmailScheduledTime: bigint("next_email_scheduled_time", { - mode: "number", - }).notNull(), - retryCount: integer("retry_count").notNull().default(0), - sentEmailIds: text("sent_email_ids").array().notNull().default([]), - processingStartedAt: timestamp("processing_started_at", { - withTimezone: true, - }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - sequenceContactIdx: uniqueIndex( - "ongoing_sequences_sequence_id_contact_id_idx", - ).on(table.sequenceId, table.contactId), - // The 60s due-poll (`getDueOngoingSequences`) filters on this column. - nextScheduledIdx: index( - "ongoing_sequences_next_email_scheduled_time_idx", - ).on(table.nextEmailScheduledTime), - }), -); - -export const emailDeliveries = pgTable("email_deliveries", { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - sequenceId: uuid("sequence_id") - .notNull() - .references(() => sequences.id, { onDelete: "cascade" }), - contactId: uuid("contact_id") - .notNull() - .references(() => contacts.id, { onDelete: "cascade" }), - emailId: uuid("email_id") - .notNull() - .references(() => sequenceEmails.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), -}); - -export const emailEvents = pgTable("email_events", { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - sequenceId: uuid("sequence_id") - .notNull() - .references(() => sequences.id, { onDelete: "cascade" }), - contactId: uuid("contact_id") - .notNull() - .references(() => contacts.id, { onDelete: "cascade" }), - emailId: uuid("email_id") - .notNull() - .references(() => sequenceEmails.id, { onDelete: "cascade" }), - action: text("action").notNull(), // open | click | bounce - link: text("link"), - linkIndex: integer("link_index"), - bounceType: text("bounce_type"), // hard | soft - bounceReason: text("bounce_reason"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), -}); - -/** A single API-triggered send — the transactional counterpart of - * `sequences`/`sequence_emails`, deliberately not modeled as either (see - * `docs/transactional-emails.md`): recipients are never required to be - * subscribed `contacts`, no unsubscribe/footer is injected, and delivery is - * immediate rather than audience-fanned-out. One row per message; the - * rendered `html` is snapshotted at send time so the log survives later - * template edits/deletes. `toEmail`/`fromEmail` are suffixed `_email` because - * `to`/`from` are reserved words in SQL. */ -export const transactionalEmails = pgTable( - "transactional_emails", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - txeId: text("txe_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("txe")), - deliverySourceType: text("delivery_source_type").notNull(), - outboxId: uuid("outbox_id").references(() => espConfigs.id, { - onDelete: "restrict", - }), - espGrantId: uuid("esp_grant_id").references( - () => espConfigTeamGrants.id, - { onDelete: "restrict" }, - ), - toEmail: text("to_email").notNull(), - // Resolved sender identity at enqueue time (team ESP fromName/fromEmail - // fallback chain, same as `attemptMailSending`) — never caller-supplied. - fromEmail: text("from_email"), - replyTo: text("reply_to"), - subject: text("subject").notNull(), - // Plain text holding the *public* `tpl_` id — same convention as - // `sequence_emails.templateId` (not a FK): informational only, never - // resolved for reads, and left dangling if the template is later - // deleted (harmless — `html`/`subject` are already snapshotted). - templateId: text("template_id"), - // Rendered snapshot actually sent (post-Liquid for template sends, - // verbatim for inline `html` sends) — pre tracking-pixel/click rewrite. - html: text("html"), - // Liquid merge payload; only meaningful alongside `templateId` (inline - // `html` sends are never re-rendered — see PRD's send-pipeline notes). - variables: jsonb("variables").notNull().default({}), - headers: jsonb("headers"), - // Populated opportunistically when `toEmail` matches an existing - // contact, purely for analytics — never consulted for suppression; - // `contacts.subscribed` does not apply to transactional mail. - contactId: uuid("contact_id").references(() => contacts.id, { - onDelete: "set null", - }), - status: text("status").notNull().default("queued"), // queued|sent|failed|bounced|suppressed|cancelled - processingStartedAt: timestamp("processing_started_at", { - withTimezone: true, - }), - error: text("error"), - idempotencyKey: text("idempotency_key"), - trackOpens: boolean("track_opens").notNull().default(false), - trackClicks: boolean("track_clicks").notNull().default(false), - openCount: integer("open_count").notNull().default(0), - clickCount: integer("click_count").notNull().default(0), - sentAt: timestamp("sent_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - txeIdCheck: publicIdCheck( - "transactional_emails_txe_id_check", - table.txeId, - "txe", - ), - // Idempotency-key replay lookup — partial, since most sends won't - // supply one and NULL is never unique-constrained. - teamIdempotencyKeyIdx: uniqueIndex( - "transactional_emails_team_id_idempotency_key_idx", - ) - .on(table.teamId, table.idempotencyKey) - .where(sql`${table.idempotencyKey} IS NOT NULL`), - teamCreatedAtIdx: index( - "transactional_emails_team_id_created_at_idx", - ).on(table.teamId, table.createdAt), - teamStatusIdx: index("transactional_emails_team_id_status_idx").on( - table.teamId, - table.status, - ), - deliveryPinCheck: check( - "transactional_emails_delivery_pin_check", - sql`( - ${table.deliverySourceType} = 'team' - AND ${table.outboxId} IS NOT NULL - AND ${table.espGrantId} IS NULL - ) OR ( - ${table.deliverySourceType} = 'organization' - AND ${table.outboxId} IS NOT NULL - AND ${table.espGrantId} IS NOT NULL - )`, - ), - }), -); - -/** Provider-specific webhook security/health lifecycle for one feedback - * connection — deliberately not columns on `esp_configs`, since secrets and - * health status churn independently of SMTP connection settings (see - * `docs/bounces-and-complaints.md#2-feedback-connection`). A `custom` - * connection is pinned to one team-owned `espConfigId`; a future `platform` - * connection (never created by this phase) has null `teamId`/`espConfigId` - * and is deployment-managed, never returned through team ESP APIs. */ -export const espFeedbackConnections = pgTable( - "esp_feedback_connections", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - connectionId: text("connection_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("whc")), - ownerScope: text("owner_scope").notNull(), // organization | team - organizationId: uuid("organization_id").references( - () => organizations.id, - { onDelete: "restrict" }, - ), - teamId: uuid("team_id").references(() => teams.id, { - onDelete: "restrict", - }), - espConfigId: uuid("esp_config_id").references(() => espConfigs.id, { - onDelete: "restrict", - }), - provider: text("provider").notNull(), - encryptedCredentials: text("encrypted_credentials"), - // Rotation accepts both the current and immediately previous - // credential for up to 24h so an in-flight provider retry signed - // with the old secret isn't rejected (PRD's "Feedback connection" - // rotation requirement). Cleared once expired. - previousEncryptedCredentials: text("previous_encrypted_credentials"), - previousCredentialExpiresAt: timestamp( - "previous_credential_expires_at", - { withTimezone: true }, - ), - // SES: the SNS TopicArn this connection expects notifications from. - expectedTopicArn: text("expected_topic_arn"), - status: text("status").notNull().default("pending"), - lastReceivedAt: timestamp("last_received_at", { withTimezone: true }), - lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }), - lastErrorCode: text("last_error_code"), - disabledAt: timestamp("disabled_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - connectionIdCheck: publicIdCheck( - "esp_feedback_connections_connection_id_check", - table.connectionId, - "whc", - ), - teamIdx: index("esp_feedback_connections_team_id_idx").on(table.teamId), - ownerCheck: check( - "esp_feedback_connections_owner_check", - sql`( - ${table.ownerScope} = 'organization' - AND ${table.organizationId} IS NOT NULL - AND ${table.teamId} IS NULL - ) OR ( - ${table.ownerScope} = 'team' - AND ${table.organizationId} IS NULL - AND ${table.teamId} IS NOT NULL - )`, - ), - // At most one non-retired connection per user ESP — a provider - // change retires the old row (status -> retiring) and inserts a new - // one rather than mutating provider in place. - espConfigActiveIdx: uniqueIndex( - "esp_feedback_connections_esp_config_active_idx", - ) - .on(table.espConfigId) - .where( - sql`${table.espConfigId} is not null and ${table.status} not in ('retiring', 'disabled')`, - ), - }), -); - -/** One row per (recipient) submission across broadcasts, sequences, and - * transactional sends — the common ledger `docs/bounces-and-complaints.md` - * requires so a later provider webhook can correlate back to a workspace, - * source, and pinned ESP regardless of which pipeline sent it. Created - * before transport and updated with the transport result; the current - * `deliveryStatus`/`feedbackStatus` are a projection maintained by - * `feedback/projection.ts`, kept separate from the immutable - * `emailDeliveryEvents` log so retries/out-of-order events can't corrupt it. */ -export const outboundMessages = pgTable( - "outbound_messages", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - messageId: text("message_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("msg")), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - deliverySourceType: text("delivery_source_type").notNull(), - espConfigId: uuid("esp_config_id").references(() => espConfigs.id, { - onDelete: "restrict", - }), - espGrantId: uuid("esp_grant_id").references( - () => espConfigTeamGrants.id, - { onDelete: "restrict" }, - ), - feedbackConnectionId: uuid("feedback_connection_id").references( - () => espFeedbackConnections.id, - { onDelete: "set null" }, - ), - sourceType: text("source_type").notNull(), // campaign | transactional - // Stable application-level submission identity. Retries reuse the - // same ledger row and RFC Message-ID rather than creating a second - // provider-correlatable message. - submissionKey: text("submission_key").unique(), - // Exactly one of these two is populated, matching `sourceType`. - campaignDeliveryId: uuid("campaign_delivery_id").references( - () => emailDeliveries.id, - { onDelete: "set null" }, - ), - transactionalEmailId: uuid("transactional_email_id").references( - () => transactionalEmails.id, - { onDelete: "set null" }, - ), - recipientEmail: text("recipient_email").notNull(), - normalizedRecipient: text("normalized_recipient").notNull(), - // Snapshot of the pinned ESP's provider at send time — never - // resolved from the team's *current* default, so historical - // correlation survives a later default switch. - provider: text("provider"), - rfcMessageId: text("rfc_message_id"), - providerMessageId: text("provider_message_id"), - deliveryStatus: text("delivery_status").notNull().default("queued"), - feedbackStatus: text("feedback_status").notNull().default("none"), - acceptedAt: timestamp("accepted_at", { withTimezone: true }), - deliveredAt: timestamp("delivered_at", { withTimezone: true }), - bouncedAt: timestamp("bounced_at", { withTimezone: true }), - complainedAt: timestamp("complained_at", { withTimezone: true }), - lastEventAt: timestamp("last_event_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - messageIdCheck: publicIdCheck( - "outbound_messages_message_id_check", - table.messageId, - "msg", - ), - teamCreatedAtIdx: index("outbound_messages_team_id_created_at_idx").on( - table.teamId, - table.createdAt, - ), - connectionProviderMsgIdx: index( - "outbound_messages_connection_provider_msg_idx", - ).on(table.feedbackConnectionId, table.providerMessageId), - recipientHistoryIdx: index( - "outbound_messages_team_id_recipient_created_at_idx", - ).on(table.teamId, table.normalizedRecipient, table.createdAt), - deliveryPinCheck: check( - "outbound_messages_delivery_pin_check", - sql`( - ${table.deliverySourceType} = 'team' - AND ${table.espConfigId} IS NOT NULL - AND ${table.espGrantId} IS NULL - ) OR ( - ${table.deliverySourceType} = 'organization' - AND ${table.espConfigId} IS NOT NULL - AND ${table.espGrantId} IS NOT NULL - ) OR ( - ${table.deliverySourceType} IN ('team', 'organization') - AND ${table.espConfigId} IS NULL - AND ${table.espGrantId} IS NULL - AND ${table.deliveryStatus} <> 'queued' - )`, - ), - }), -); - -/** Atomic quota counters for organization-owned delivery. */ -export const organizationEspUsageBuckets = pgTable( - "organization_esp_usage_buckets", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - bucketScope: text("bucket_scope").notNull(), // grant | organization - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - grantId: uuid("grant_id").references(() => espConfigTeamGrants.id, { - onDelete: "restrict", - }), - periodType: text("period_type").notNull(), // day | month - periodStart: timestamp("period_start", { - withTimezone: true, - }).notNull(), - reservedCount: integer("reserved_count").notNull().default(0), - acceptedCount: integer("accepted_count").notNull().default(0), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - scopeCheck: check( - "organization_esp_usage_buckets_scope_check", - sql`( - ${table.bucketScope} = 'grant' AND ${table.grantId} IS NOT NULL - ) OR ( - ${table.bucketScope} = 'organization' AND ${table.grantId} IS NULL - )`, - ), - periodCheck: check( - "organization_esp_usage_buckets_period_check", - sql`${table.periodType} IN ('day', 'month')`, - ), - countCheck: check( - "organization_esp_usage_buckets_count_check", - sql`${table.reservedCount} >= 0 AND ${table.acceptedCount} >= 0`, - ), - grantPeriodIdx: uniqueIndex( - "organization_esp_usage_buckets_grant_period_idx", - ) - .on(table.grantId, table.periodType, table.periodStart) - .where(sql`${table.grantId} IS NOT NULL`), - organizationPeriodIdx: uniqueIndex( - "organization_esp_usage_buckets_organization_period_idx", - ) - .on(table.organizationId, table.periodType, table.periodStart) - .where(sql`${table.bucketScope} = 'organization'`), - grantOrganizationFk: foreignKey({ - name: "organization_esp_usage_buckets_grant_organization_fk", - columns: [table.grantId, table.organizationId], - foreignColumns: [ - espConfigTeamGrants.id, - espConfigTeamGrants.organizationId, - ], - }).onDelete("restrict"), - }), -); - -export const organizationEspQuotaReservations = pgTable( - "organization_esp_quota_reservations", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - reservationId: text("reservation_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("qrs")), - outboundMessageId: uuid("outbound_message_id") - .notNull() - .unique() - .references(() => outboundMessages.id, { onDelete: "restrict" }), - grantId: uuid("grant_id") - .notNull() - .references(() => espConfigTeamGrants.id, { - onDelete: "restrict", - }), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "restrict" }), - dayPeriodStart: timestamp("day_period_start", { - withTimezone: true, - }).notNull(), - monthPeriodStart: timestamp("month_period_start", { - withTimezone: true, - }).notNull(), - state: text("state").notNull().default("reserved"), - releaseReason: text("release_reason"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - committedAt: timestamp("committed_at", { withTimezone: true }), - releasedAt: timestamp("released_at", { withTimezone: true }), - }, - (table) => ({ - reservationIdCheck: publicIdCheck( - "organization_esp_quota_reservations_reservation_id_check", - table.reservationId, - "qrs", - ), - stateCheck: check( - "organization_esp_quota_reservations_state_check", - sql`${table.state} IN ('reserved', 'committed', 'released')`, - ), - grantOrganizationFk: foreignKey({ - name: "organization_esp_quota_reservations_grant_organization_fk", - columns: [table.grantId, table.organizationId], - foreignColumns: [ - espConfigTeamGrants.id, - espConfigTeamGrants.organizationId, - ], - }).onDelete("restrict"), - }), -); - -/** Transactional hand-off between PostgreSQL and BullMQ. */ -export const mailDispatchOutbox = pgTable( - "mail_dispatch_outbox", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - dispatchId: text("dispatch_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("mdj")), - outboundMessageId: uuid("outbound_message_id") - .notNull() - .unique() - .references(() => outboundMessages.id, { onDelete: "restrict" }), - queueName: text("queue_name").notNull(), - jobName: text("job_name").notNull(), - state: text("state").notNull().default("pending"), - availableAt: timestamp("available_at", { withTimezone: true }) - .notNull() - .defaultNow(), - leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), - publishAttempts: integer("publish_attempts").notNull().default(0), - lastError: text("last_error"), - publishedAt: timestamp("published_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - dispatchIdCheck: publicIdCheck( - "mail_dispatch_outbox_dispatch_id_check", - table.dispatchId, - "mdj", - ), - stateCheck: check( - "mail_dispatch_outbox_state_check", - sql`${table.state} IN ('pending', 'publishing', 'published', 'cancelled')`, - ), - dueIdx: index("mail_dispatch_outbox_due_idx").on( - table.state, - table.availableAt, - ), - }), -); - -/** Durable, authenticated inbox for raw provider webhook bodies — a request - * is inserted here and committed *before* the HTTP response is sent, so an - * acknowledged provider retry can never be lost even if BullMQ/Redis is - * briefly unavailable (see `docs/bounces-and-complaints.md#4-durable-receipt-inbox`). - * `teamId` is only ever populated from a `custom` connection — a platform - * receipt may bundle multiple workspaces in one payload, so team ownership - * is assigned later, per normalized event, from a uniquely matched - * `outboundMessages` row. */ -export const espWebhookReceipts = pgTable( - "esp_webhook_receipts", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - receiptId: text("receipt_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("whr")), - connectionId: uuid("connection_id") - .notNull() - .references(() => espFeedbackConnections.id, { - onDelete: "cascade", - }), - teamId: uuid("team_id").references(() => teams.id, { - onDelete: "cascade", - }), - provider: text("provider").notNull(), - providerRequestId: text("provider_request_id"), - bodySha256: text("body_sha256").notNull(), - // Encrypted raw payload (AES-256-GCM, same utility as ESP - // credentials) — required on receipt, set null only once the - // 30-day raw-retention purge runs (see PRD's privacy/retention). - encryptedPayload: text("encrypted_payload"), - // Allowlisted non-secret headers only — never Authorization, - // Cookie, or signature headers. - safeHeaders: jsonb("safe_headers").notNull().default({}), - status: text("status").notNull().default("pending"), - processingAttempts: integer("processing_attempts").notNull().default(0), - nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }), - lastErrorCode: text("last_error_code"), - receivedAt: timestamp("received_at", { withTimezone: true }) - .notNull() - .defaultNow(), - processedAt: timestamp("processed_at", { withTimezone: true }), - }, - (table) => ({ - receiptIdCheck: publicIdCheck( - "esp_webhook_receipts_receipt_id_check", - table.receiptId, - "whr", - ), - // The pending-receipt poller's recovery query and the worker's - // claim query both filter on this pair. - statusNextAttemptIdx: index( - "esp_webhook_receipts_status_next_attempt_idx", - ).on(table.status, table.nextAttemptAt), - connectionRequestIdx: index( - "esp_webhook_receipts_connection_id_provider_request_id_idx", - ).on(table.connectionId, table.providerRequestId), - }), -); - -/** One immutable row per canonical provider event, derived from a receipt by - * a provider adapter — the event log `docs/bounces-and-complaints.md` - * requires to keep retries/out-of-order delivery from corrupting the - * `outboundMessages` projection. Never updated after insert. */ -export const emailDeliveryEvents = pgTable( - "email_delivery_events", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - eventId: text("event_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("evt")), - receiptId: uuid("receipt_id") - .notNull() - .references(() => espWebhookReceipts.id, { onDelete: "cascade" }), - connectionId: uuid("connection_id") - .notNull() - .references(() => espFeedbackConnections.id, { - onDelete: "cascade", - }), - // Null until a platform event is uniquely correlated — see - // `outboundMessageId` note below and the PRD's correlation section. - teamId: uuid("team_id").references(() => teams.id, { - onDelete: "cascade", - }), - outboundMessageId: uuid("outbound_message_id").references( - () => outboundMessages.id, - { onDelete: "set null" }, - ), - provider: text("provider").notNull(), - // Deterministic per-adapter idempotency key (e.g. `sg_event_id`, or - // a composed key when the provider doesn't guarantee one) — see - // each adapter's "stable event key" requirement in the PRD. - providerEventKey: text("provider_event_key").notNull(), - providerMessageId: text("provider_message_id"), - recipientEmail: text("recipient_email"), - normalizedRecipient: text("normalized_recipient"), - eventType: text("event_type").notNull(), - bounceClass: text("bounce_class"), - smtpCode: integer("smtp_code"), - enhancedStatusCode: text("enhanced_status_code"), - reason: text("reason"), - remoteMta: text("remote_mta"), - occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(), - receivedAt: timestamp("received_at", { withTimezone: true }).notNull(), - metadata: jsonb("metadata").notNull().default({}), - }, - (table) => ({ - eventIdCheck: publicIdCheck( - "email_delivery_events_event_id_check", - table.eventId, - "evt", - ), - // Event idempotency: replaying the same provider event twice must - // insert nothing the second time. - connectionEventKeyIdx: uniqueIndex( - "email_delivery_events_connection_id_provider_event_key_idx", - ).on(table.connectionId, table.providerEventKey), - teamOccurredAtIdx: index( - "email_delivery_events_team_id_occurred_at_idx", - ).on(table.teamId, table.occurredAt), - outboundMessageIdx: index( - "email_delivery_events_outbound_message_id_idx", - ).on(table.outboundMessageId), - }), -); - -/** Per-workspace do-not-send list — deliberately not derived from - * `contacts.subscribed` or the latest message status, so it survives - * contact deletion/reimport and stays route-independent (any pinned custom - * ESP, and eventually the platform route, must all respect it). One row per - * `(teamId, recipientHash)`; repeated signals update `lastSuppressedAt` and - * keep the strongest `reason` (see `suppressionReasonStrength`). */ -export const emailSuppressions = pgTable( - "email_suppressions", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - suppressionId: text("suppression_id") - .notNull() - .unique() - .$defaultFn(() => genPublicId("sup")), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - // Presented/normalized address — both nulled out (HMAC retained) by - // a recipient privacy-erasure deletion; see PRD's retention section. - recipientEmail: text("recipient_email"), - normalizedRecipient: text("normalized_recipient"), - recipientHash: text("recipient_hash").notNull(), - hashKeyVersion: integer("hash_key_version").notNull(), - reason: text("reason").notNull(), - sourceEventId: uuid("source_event_id").references( - () => emailDeliveryEvents.id, - { onDelete: "set null" }, - ), - active: boolean("active").notNull().default(true), - firstSuppressedAt: timestamp("first_suppressed_at", { - withTimezone: true, - }) - .notNull() - .defaultNow(), - lastSuppressedAt: timestamp("last_suppressed_at", { - withTimezone: true, - }) - .notNull() - .defaultNow(), - releasedAt: timestamp("released_at", { withTimezone: true }), - releasedBy: text("released_by").references(() => user.id, { - onDelete: "set null", - }), - releaseReason: text("release_reason"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - suppressionIdCheck: publicIdCheck( - "email_suppressions_suppression_id_check", - table.suppressionId, - "sup", - ), - teamHashIdx: uniqueIndex( - "email_suppressions_team_id_recipient_hash_idx", - ).on(table.teamId, table.recipientHash), - teamActiveIdx: index("email_suppressions_team_id_active_idx").on( - table.teamId, - table.active, - ), - }), -); - -/** Append-only audit trail for every suppression create/reason-change/ - * release/reactivate — required so a permitted release is always - * attributable (PRD acceptance criterion: "every permitted release is - * audited"). Never updated or deleted by application code. */ -export const emailSuppressionActions = pgTable( - "email_suppression_actions", - { - id: uuid("id").$defaultFn(genId).primaryKey(), - teamId: uuid("team_id") - .notNull() - .references(() => teams.id, { onDelete: "cascade" }), - suppressionId: uuid("suppression_id") - .notNull() - .references(() => emailSuppressions.id, { onDelete: "cascade" }), - sourceEventId: uuid("source_event_id").references( - () => emailDeliveryEvents.id, - { onDelete: "set null" }, - ), - action: text("action").notNull(), // created | reason_changed | released | reactivated - actorType: text("actor_type").notNull(), // system | workspace_user | sendlit_operator - actorUserId: text("actor_user_id").references(() => user.id, { - onDelete: "set null", - }), - explanation: text("explanation"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => ({ - suppressionActionsIdx: index( - "email_suppression_actions_suppression_id_created_at_idx", - ).on(table.suppressionId, table.createdAt), - }), -); +export * from "./schema-core"; +export * from "./billing.generated"; +export * from "./billing-extensions"; diff --git a/apps/api/src/organization/queries.test.ts b/apps/api/src/organization/queries.test.ts index 39c9337..d4dff62 100644 --- a/apps/api/src/organization/queries.test.ts +++ b/apps/api/src/organization/queries.test.ts @@ -15,8 +15,8 @@ import { espConfigs, organizationEspQuotaReservations, organizationEspUsageBuckets, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, organizations, outboundMessages, sequences, @@ -384,7 +384,7 @@ describe("organizations", () => { const [price] = await tdb .insert(billingPriceEntries) .values({ - catalogKey: "pro_month", + offerKey: "pro_month", plan: "pro", billingInterval: "month", currency: "USD", @@ -394,11 +394,12 @@ describe("organizations", () => { }) .returning(); await tdb.insert(billingCheckoutAttempts).values({ - organizationId: organization.id, - payerUserId: owner.id, + attemptId: `bca_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`, + billableEntityId: organization.id, + payerId: owner.id, provider: "dodo", catalogRevision: 1, - catalogKey: "pro_month", + offerKey: "pro_month", requestedPlan: "pro", requestedInterval: "month", billingPriceEntryId: price.id, @@ -455,7 +456,7 @@ describe("one owned Free organization", () => { const [price] = await tdb .insert(billingPriceEntries) .values({ - catalogKey: "pro_month", + offerKey: "pro_month", plan: "pro", billingInterval: "month", currency: "USD", @@ -468,23 +469,24 @@ describe("one owned Free organization", () => { .insert(billingProviderCustomers) .values({ provider: "dodo", - userId: ownerId, + payerId: ownerId, providerCustomerId: `cus_${crypto.randomUUID()}`, idempotencyKey: `customer:dodo:${ownerId}`, status: "active", }) .returning(); const [subscription] = await tdb - .insert(organizationSubscriptions) + .insert(billingSubscriptions) .values({ - organizationId, + billableEntityId: organizationId, billingCustomerId: customer.id, - billingManagerUserId: ownerId, + payerId: ownerId, provider: "dodo", providerSubscriptionId: `sub_${crypto.randomUUID()}`, providerProductId: price.providerProductId, billingPriceEntryId: price.id, - catalogKey: "pro_month", + catalogRevision: 1, + offerKey: "pro_month", plan: "pro", billingInterval: "month", status: input.status, @@ -494,12 +496,12 @@ describe("one owned Free organization", () => { }) .returning(); await tdb - .update(organizationPlanStates) + .update(billingPlanStates) .set({ plan: "pro", activeSubscriptionId: subscription.id, }) - .where(eq(organizationPlanStates.organizationId, organizationId)); + .where(eq(billingPlanStates.billableEntityId, organizationId)); return subscription; } diff --git a/apps/api/src/organization/queries.ts b/apps/api/src/organization/queries.ts index 18a0003..237379a 100644 --- a/apps/api/src/organization/queries.ts +++ b/apps/api/src/organization/queries.ts @@ -7,8 +7,8 @@ import { organizationDeliveryPolicies, espConfigTeamGrants, organizations, - organizationPlanStates, - organizationSubscriptions, + billingPlanStates, + billingSubscriptions, settings, teams, teamDeliverySettings, @@ -34,10 +34,10 @@ async function lockOrganizationSubscriptions( organizationId: string, ) { return tx - .select({ id: organizationSubscriptions.id }) - .from(organizationSubscriptions) - .where(eq(organizationSubscriptions.organizationId, organizationId)) - .orderBy(asc(organizationSubscriptions.id)) + .select({ id: billingSubscriptions.id }) + .from(billingSubscriptions) + .where(eq(billingSubscriptions.billableEntityId, organizationId)) + .orderBy(asc(billingSubscriptions.id)) .for("update"); } @@ -54,8 +54,8 @@ async function ownsEffectiveFreeOrganization( ): Promise { const owned = await tx .select({ - planState: organizationPlanStates, - subscription: organizationSubscriptions, + planState: billingPlanStates, + subscription: billingSubscriptions, }) .from(organizationMembers) .innerJoin( @@ -63,16 +63,16 @@ async function ownsEffectiveFreeOrganization( eq(organizations.id, organizationMembers.organizationId), ) .innerJoin( - organizationPlanStates, - eq(organizationPlanStates.organizationId, organizations.id), + billingPlanStates, + eq(billingPlanStates.billableEntityId, organizations.id), ) .leftJoin( - organizationSubscriptions, + billingSubscriptions, and( - eq(organizationSubscriptions.organizationId, organizations.id), + eq(billingSubscriptions.billableEntityId, organizations.id), eq( - organizationSubscriptions.id, - organizationPlanStates.activeSubscriptionId, + billingSubscriptions.id, + billingPlanStates.activeSubscriptionId, ), ), ) @@ -85,7 +85,7 @@ async function ownsEffectiveFreeOrganization( ); return owned.some(({ planState, subscription }) => { const entitlements = resolveEntitlements({ - organizationId: planState.organizationId, + organizationId: planState.billableEntityId, deploymentMode: process.env.SENDLIT_DEPLOYMENT_MODE === "cloud" ? "cloud" @@ -357,7 +357,7 @@ export async function updateOrganizationName( export async function abandonPendingOrganization( organizationId: string, - actorUserId: string, + actorId: string, ): Promise { await db.transaction(async (tx) => { const [organization] = await tx @@ -376,7 +376,7 @@ export async function abandonPendingOrganization( .where( and( eq(organizationMembers.organizationId, organizationId), - eq(organizationMembers.userId, actorUserId), + eq(organizationMembers.userId, actorId), ), ) .limit(1); @@ -393,7 +393,10 @@ export async function abandonPendingOrganization( }) .where( and( - eq(billingCheckoutAttempts.organizationId, organizationId), + eq( + billingCheckoutAttempts.billableEntityId, + organizationId, + ), inArray(billingCheckoutAttempts.status, [ "creating", "open", @@ -406,7 +409,7 @@ export async function abandonPendingOrganization( .where(eq(organizations.id, organizationId)); await recordOrganizationAuditEvent(tx, { organizationId, - actor: { type: "user", id: actorUserId }, + actor: { type: "user", id: actorId }, action: "organization.pending_abandoned", metadata: {}, }); @@ -422,38 +425,43 @@ export async function closeOrganization( type: "system", }, ): Promise { + const { getBillingEngine } = await import("../billing/engine.js"); + const blockers = await getBillingEngine().getBillableEntityBillingBlockers( + { kind: "organization", id: organizationId }, + new Date(), + ); + if ( + blockers.includes("nonterminal_subscription") || + blockers.includes("future_paid_entitlement") + ) { + throw new Error("active_subscription_exists"); + } + if (blockers.includes("live_checkout")) { + throw new Error("billing_checkout_pending"); + } await db.transaction(async (tx) => { const liveSubscriptions = await tx - .select({ id: organizationSubscriptions.id }) - .from(organizationSubscriptions) + .select({ id: billingSubscriptions.id }) + .from(billingSubscriptions) .where( and( - eq( - organizationSubscriptions.organizationId, - organizationId, - ), + eq(billingSubscriptions.billableEntityId, organizationId), or( - inArray(organizationSubscriptions.status, [ + inArray(billingSubscriptions.status, [ "pending", "trialing", "active", "past_due", ]), and( - eq(organizationSubscriptions.status, "cancelled"), - eq( - organizationSubscriptions.cancelAtPeriodEnd, - true, - ), - gt( - organizationSubscriptions.paidThroughAt, - new Date(), - ), + eq(billingSubscriptions.status, "cancelled"), + eq(billingSubscriptions.cancelAtPeriodEnd, true), + gt(billingSubscriptions.paidThroughAt, new Date()), ), ), ), ) - .orderBy(asc(organizationSubscriptions.id)) + .orderBy(asc(billingSubscriptions.id)) .for("update"); if (liveSubscriptions.length > 0) { throw new Error("active_subscription_exists"); @@ -463,7 +471,10 @@ export async function closeOrganization( .from(billingCheckoutAttempts) .where( and( - eq(billingCheckoutAttempts.organizationId, organizationId), + eq( + billingCheckoutAttempts.billableEntityId, + organizationId, + ), inArray(billingCheckoutAttempts.status, [ "creating", "open", @@ -625,23 +636,23 @@ async function assertBillingManagerRetained( userId: string, ): Promise { const [subscription] = await tx - .select({ id: organizationSubscriptions.id }) - .from(organizationSubscriptions) + .select({ id: billingSubscriptions.id }) + .from(billingSubscriptions) .where( and( - eq(organizationSubscriptions.organizationId, organizationId), - eq(organizationSubscriptions.billingManagerUserId, userId), + eq(billingSubscriptions.billableEntityId, organizationId), + eq(billingSubscriptions.payerId, userId), or( - inArray(organizationSubscriptions.status, [ + inArray(billingSubscriptions.status, [ "pending", "trialing", "active", "past_due", ]), and( - eq(organizationSubscriptions.status, "cancelled"), - eq(organizationSubscriptions.cancelAtPeriodEnd, true), - gt(organizationSubscriptions.paidThroughAt, new Date()), + eq(billingSubscriptions.status, "cancelled"), + eq(billingSubscriptions.cancelAtPeriodEnd, true), + gt(billingSubscriptions.paidThroughAt, new Date()), ), ), ), diff --git a/apps/api/src/test/db.ts b/apps/api/src/test/db.ts index 9821418..37beacd 100644 --- a/apps/api/src/test/db.ts +++ b/apps/api/src/test/db.ts @@ -63,10 +63,11 @@ export async function truncateAll(db: Awaited>) { await db.delete(schema.teams); await db.delete(schema.sendingDomains); await db.delete(schema.billingTrialClaims); + await db.delete(schema.billingReconciliationJobs); await db.delete(schema.billingPlanChangeAttempts); await db.delete(schema.billingCheckoutAttempts); - await db.delete(schema.organizationPlanStates); - await db.delete(schema.organizationSubscriptions); + await db.delete(schema.billingPlanStates); + await db.delete(schema.billingSubscriptions); await db.delete(schema.billingWebhookEvents); await db.delete(schema.billingCatalogRevisionItems); await db.delete(schema.billingCatalogRevisions); @@ -122,9 +123,11 @@ export async function seedTeamAndContact( await db.insert(schema.organizationDeliveryPolicies).values({ organizationId: organization.id, }); - await db.insert(schema.organizationPlanStates).values({ - organizationId: organization.id, + await db.insert(schema.billingPlanStates).values({ + billableEntityId: organization.id, plan: "free", + rampStage: 0, + rampCleanStageDays: 0, }); const [team] = await db diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts index ca19b8b..6fff13f 100644 --- a/apps/web/lib/api.test.ts +++ b/apps/web/lib/api.test.ts @@ -45,4 +45,48 @@ describe("dashboard API client auth handling", () => { expect(location.href).toBe("/login"); }); + + it("does not sign out on billing recent-authentication 401", async () => { + const location = installWindow("/organizations"); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/auth/sign-out")) { + return new Response(null, { status: 204 }); + } + return new Response( + JSON.stringify({ error: "recent_authentication_required" }), + { + status: 401, + headers: { "content-type": "application/json" }, + }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("document", { cookie: "" }); + + const { createOrganizationBillingCheckout } = await import("./api"); + await expect( + createOrganizationBillingCheckout("org_cB2kKmowUP3UeOHPYpzcp8S6", { + plan: "pro", + interval: "month", + catalogRevision: 1, + }), + ).rejects.toMatchObject({ + status: 401, + message: "recent_authentication_required", + }); + expect(location.href).toBe("/organizations"); + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes("/api/proxy/billing/action-token"), + ), + ).toBe(true); + expect( + fetchMock.mock.calls.some( + ([url, init]) => + String(url).includes("/api/auth/sign-out") && + (init as RequestInit | undefined)?.method === "POST", + ), + ).toBe(false); + }); }); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index d1e5fec..fcab44e 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -737,12 +737,10 @@ async function organizationRequest( error?: string; } | null; if (response.status === 401 && typeof window !== "undefined") { + // Step-up auth for billing, not a dead session. Keep the dashboard + // signed in and let the billing dialog show the error. if (body?.error === "recent_authentication_required") { - // A normal session refresh is not reauthentication. End the old - // session so the hosted email-OTP login must establish a new one. - await fetch("/api/auth/sign-out", { method: "POST" }).catch( - () => undefined, - ); + throw new ApiError(401, "recent_authentication_required"); } window.location.href = "/login"; return new Promise(() => {}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4127d0a..d5e13fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: '@better-auth/oauth-provider': specifier: 1.7.0-rc.4 version: 1.7.0-rc.4(@better-auth/core@1.7.0-rc.4(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.4(@opentelemetry/api@1.9.1)(drizzle-kit@0.28.1(supports-color@5.5.0))(drizzle-orm@0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1(supports-color@5.5.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.19.12)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@3.25.76)) + '@codelitdev/billing': + specifier: 0.1.0-alpha.3 + version: 0.1.0-alpha.3(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(dodopayments@2.48.0)(kysely@0.29.3)(pg@8.22.0) '@codelitdev/oauth-server-kit': specifier: 0.1.0-alpha.1 version: 0.1.0-alpha.1(6e1caef0c85a7dbb3bde5a382f6e4e4f) @@ -670,7 +673,7 @@ importers: version: 10.5.2(postcss@8.5.16) eslint: specifier: ^8.57.0 - version: 8.57.1 + version: 8.57.1(supports-color@5.5.0) postcss: specifier: ^8.4.35 version: 8.5.16 @@ -691,10 +694,10 @@ importers: version: 4.9.5 typescript-eslint: specifier: ^7.4.0 - version: 7.18.0(eslint@8.57.1)(typescript@4.9.5) + version: 7.18.0(eslint@8.57.1(supports-color@5.5.0))(supports-color@5.5.0)(typescript@4.9.5) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) packages/email-editor: dependencies: @@ -761,7 +764,7 @@ importers: version: 10.5.2(postcss@8.5.16) eslint: specifier: ^8.57.0 - version: 8.57.1(supports-color@5.5.0) + version: 8.57.1 postcss: specifier: ^8.4.35 version: 8.5.16 @@ -782,10 +785,10 @@ importers: version: 4.9.5 typescript-eslint: specifier: ^7.4.0 - version: 7.18.0(eslint@8.57.1(supports-color@5.5.0))(supports-color@5.5.0)(typescript@4.9.5) + version: 7.18.0(eslint@8.57.1)(typescript@4.9.5) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(jsdom@25.0.1)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -1086,6 +1089,15 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@codelitdev/billing@0.1.0-alpha.3': + resolution: {integrity: sha512-tWUwb/+v1asm+hB9DaPLThT/Ky1nN55aIbCafijqpHeqZvOlrc3vQzD/wyq8gjckS/ZRx1sUUOkuYZ7f/DVIPQ==} + hasBin: true + peerDependencies: + dodopayments: ^2.48.0 + peerDependenciesMeta: + dodopayments: + optional: true + '@codelitdev/design-system@0.1.0-alpha.6': resolution: {integrity: sha512-hMqlHp0o1p+qufYWGP1GXfQFjIcei5epOOpjRO0b86MXHA0Tzg845HfK6ALxSGJ6wEu2ksdCNQ8aXDHIjmdc6g==} peerDependencies: @@ -9305,6 +9317,45 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@codelitdev/billing@0.1.0-alpha.3(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(dodopayments@2.48.0)(kysely@0.29.3)(pg@8.22.0)': + dependencies: + drizzle-orm: 0.45.2(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.3)(pg@8.22.0) + nanoid: 5.1.16 + tsx: 4.22.4 + zod: 3.25.76 + optionalDependencies: + dodopayments: 2.48.0 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@prisma/client' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - sql.js + - sqlite3 + '@codelitdev/design-system@0.1.0-alpha.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aaea460..48ca3f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ allowBuilds: minimumReleaseAgeExclude: - '@codelitdev/design-system@0.1.0-alpha.0 || 0.1.0-alpha.2 || 0.1.0-alpha.3 || 0.1.0-alpha.4 || 0.1.0-alpha.5 || 0.1.0-alpha.6' - '@codelitdev/oauth-server-kit@0.1.0-alpha.0 || 0.1.0-alpha.1' + - '@codelitdev/billing@0.1.0-alpha.0 || 0.1.0-alpha.1 || 0.1.0-alpha.2 || 0.1.0-alpha.3' overrides: "@types/node": ^22.14.1