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/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/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..f131fad --- /dev/null +++ b/apps/api/docs/pricing-plans-and-payments-integration.md @@ -0,0 +1,2004 @@ +# 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. 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 + 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.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_billing.sql b/apps/api/drizzle/0005_billing.sql new file mode 100644 index 0000000..6f1a8ef --- /dev/null +++ b/apps/api/drizzle/0005_billing.sql @@ -0,0 +1,541 @@ +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, + "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 DEFAULT gen_random_uuid() 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 DEFAULT gen_random_uuid() NOT NULL, + "attempt_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, + "offer_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, + "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_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 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 DEFAULT gen_random_uuid() NOT NULL, + "provider" 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, + "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_reconciliation_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "provider" text NOT NULL, + "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, + "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, + "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_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 "billing_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "billable_entity_id" uuid NOT NULL, + "billing_customer_id" uuid 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_revision" integer NOT NULL, + "offer_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, + "cancel_at_period_end" boolean DEFAULT false NOT NULL, + "is_entitlement_source" boolean DEFAULT false NOT NULL, + "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, + "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 "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, + "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 "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 +ALTER TABLE "organizations" DROP CONSTRAINT "organizations_status_check";--> 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 +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_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_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 "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_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 "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 "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 "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 +DO $$ BEGIN + 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 "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 "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 "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 "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 +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 +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 +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 +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 +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_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_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_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 +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 +-- 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 new file mode 100644 index 0000000..8a16636 --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,9313 @@ +{ + "id": "a438fbda-6466-4ce2-9b07-01d733f4c192", + "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.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.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 + }, + "public.billing_catalog_revision_items": { + "name": "billing_catalog_revision_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "catalog_revision_id": { + "name": "catalog_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_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": "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" + } + ], + "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, + "default": "gen_random_uuid()" + }, + "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, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_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 + }, + "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()" + }, + "pending_team_name": { + "name": "pending_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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_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": { + "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": "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": { + "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" + }, + "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.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 + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_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 + }, + "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 + }, + "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 + }, + "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_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": { + "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": "restrict", + "onUpdate": "no action" + }, + "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" + }, + "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" + } + }, + "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')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_states": { + "name": "billing_plan_states", + "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 + }, + "active_subscription_id": { + "name": "active_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "projection_version": { + "name": "projection_version", + "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()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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": {}, + "foreignKeys": { + "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": "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": { + "billing_plan_states_billable_entity_id_unique": { + "name": "billing_plan_states_billable_entity_id_unique", + "nullsNotDistinct": false, + "columns": ["billable_entity_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "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_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": 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_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": {}, + "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')" + } + }, + "isRLSEnabled": false + }, + "public.billing_provider_customers": { + "name": "billing_provider_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "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_payer_uidx": { + "name": "billing_provider_customers_provider_payer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payer_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_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": "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_reconciliation_jobs": { + "name": "billing_reconciliation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan_change_attempt_id": { + "name": "plan_change_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reconcile'" + }, + "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()" + }, + "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 + }, + "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_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": "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": "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": { + "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": "restrict", + "onUpdate": "no action" + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "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.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 + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_checkout_attempt_id": { + "name": "origin_checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_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 + }, + "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 + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_version": { + "name": "provider_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_observed_at": { + "name": "last_observed_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()" + }, + "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": { + "billing_subscriptions_provider_subscription_uidx": { + "name": "billing_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": {} + }, + "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": { + "billing_subscriptions_billable_entity_id_organizations_id_fk": { + "name": "billing_subscriptions_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "organizations", + "columnsFrom": ["billable_entity_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "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": { + "billing_subscriptions_status_check": { + "name": "billing_subscriptions_status_check", + "value": "\"billing_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" + }, + "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.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "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 + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_key_version": { + "name": "payload_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_key_version": { + "name": "verified_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.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 + } + }, + "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..c31f74a 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": 1788113568973, + "tag": "0005_billing", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index d96952e..4e3423d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,12 +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", @@ -21,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", @@ -32,6 +38,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..84e4c8b --- /dev/null +++ b/apps/api/scripts/billing.ts @@ -0,0 +1,342 @@ +import "dotenv/config"; + +import { and, eq } from "drizzle-orm"; +import { db, pool } from "../src/db/client"; +import { + billingCatalogRevisions, + billingWebhookEvents, + billingPlanStates, + billingSubscriptions, + organizations, + teams, +} from "../src/db/schema"; +import { readBillingConfig } from "../src/billing/catalog"; +import { + abandonCatalogRevision, + recordRequestedCatalogRevision, + verifyCatalogAgainstProvider, +} from "../src/billing/catalog-store"; +import { + applyTeamSendingControl, + releaseTeamSendingControl, +} from "../src/billing/reputation"; +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; + } + await verifyCatalogAgainstProvider(config); + 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(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.billableEntityId, organization.id), + eq(billingSubscriptions.isEntitlementSource, true), + ), + ) + .limit(1); + if (!subscription) { + console.log("no entitlement-bearing subscription"); + return; + } + const { getBillingOperations } = await import("../src/billing/engine.js"); + await getBillingOperations().reconcileSubscription( + { actorId: "operator", reason: "cli reconcile-org" }, + subscription.id, + ); + console.log("reconciled", publicId); +} + +async function webhookRetry(eventId: string | undefined) { + if (!eventId) usage(); + 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); +} + +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) { + 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( + { + 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(billingPlanStates) + .set({ + teamsLimitOverride: parse(teamsRaw), + contactsLimitOverride: parse(contactsRaw), + updatedAt: new Date(), + }) + .where(eq(billingPlanStates.billableEntityId, 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(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.billableEntityId, organization.id), + eq(billingSubscriptions.isEntitlementSource, true), + ), + ) + .limit(1); + if (!subscription) { + console.error("no live subscription"); + process.exit(1); + } + 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, + 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..22036ea --- /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, + billingSubscriptions, +} 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(billingSubscriptions) + .where( + and( + inArray(billingSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + "cancelled", + ]), + or( + isNull(billingSubscriptions.lastReconciledAt), + lt( + billingSubscriptions.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/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 new file mode 100644 index 0000000..964c281 --- /dev/null +++ b/apps/api/src/billing/catalog-store.test.ts @@ -0,0 +1,68 @@ +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"; +import { + getBillingProvider, + resetBillingProviderInstances, +} from "./provider-registry"; + +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(); + resetBillingProviderInstances(); + await truncateAll(tdb); +}); + +describe("catalog verification against the fake adapter", () => { + it("activates a four-offer revision when products match", async () => { + 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 = getBillingProvider("fake") as FakeBillingProvider; + provider.seedProduct({ + provider: "fake", + providerProductId: "pdt_pro_month", + currency: "USD", + amountMinor: 9900, + interval: "month", + }); + await expect( + 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 new file mode 100644 index 0000000..79d3321 --- /dev/null +++ b/apps/api/src/billing/catalog-store.ts @@ -0,0 +1,244 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCatalogRevisionItems, + billingCatalogRevisions, + billingPriceEntries, +} from "../db/schema"; +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") { + 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 recorded = await getBillingEngine().recordRequestedCatalog(); + recordBillingMetric("billing.catalog.revision_recorded", { + revision: config.catalogRevision, + status: recorded?.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({ + offerKey: billingCatalogRevisionItems.offerKey, + 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" && + packageCheckoutIsAvailable({ + requestedRevision: config.catalogRevision, + activeRevision, + }) + ); +} + +async function verifyOffer( + provider: BillingProviderAdapter, + offer: BillingOffer, +) { + const snapshot = await provider.retrieveProduct(offer.providerProductId); + if (!catalogMatchesProviderSnapshot(toPackageOffer(offer), snapshot)) { + 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; + const result = await getBillingEngine().verifyRequestedCatalog(); + if (result.mismatches.includes("revision_abandoned")) return; + if (!result.verified) { + logger.error( + { + mismatches: result.mismatches, + revision: result.revision, + }, + "billing catalog verification failed", + ); + recordBillingMetric("billing.catalog.invalid", { + revision: result.revision, + }); + await pageBillingAlert({ + code: "catalog_invalid", + message: + "The billing catalog revision is invalid; checkout is frozen.", + details: { + count: result.mismatches.length, + mismatches: result.mismatches.join(","), + }, + }).catch(() => undefined); + throw new BillingCatalogUnavailableError("billing_catalog_unavailable"); + } + recordBillingMetric("billing.catalog.activated", { + revision: result.revision, + }); + void _provider; +} + +/** 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) { + logger.error( + { + error: providerErrorSummary(error), + catalog_key: offer.catalogKey, + }, + "billing checkout catalog mismatch", + ); + recordBillingMetric("billing.catalog.checkout_mismatch", { + catalog_key: offer.catalogKey, + }); + throw new BillingCatalogUnavailableError(); + } +} + +export async function requireActiveCatalog( + config: BillingConfig, + _provider: BillingProviderAdapter, +) { + void _provider; + 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 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) + .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..5a365ec --- /dev/null +++ b/apps/api/src/billing/catalog.ts @@ -0,0 +1,347 @@ +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", + "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 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< + 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"); + } + + validateCatalog({ + offers: offers.map(toPackageOffer), + requiredOfferKeys: billingCatalogKeys, + revision, + checkoutProvider, + }); + + 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..c8a4183 --- /dev/null +++ b/apps/api/src/billing/checkout.ts @@ -0,0 +1,467 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "../db/client"; +import { billingTrialClaims, organizations, user } from "../db/schema"; +import { + fingerprintVerifiedEmail, + getBillingOffer, + readBillingConfig, + trialHmacSecrets, +} from "./catalog"; +import { requireActiveCatalog, verifyCheckoutOffer } from "./catalog-store"; +import { getBillingProvider } from "./provider-registry"; +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( + 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" + | "pending_organization_exists" + | "payment_required", + public readonly status: 400 | 401 | 402 | 403 | 409 | 503, + public readonly details: Record = {}, + ) { + super(code); + this.name = "BillingCheckoutError"; + } +} + +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, + currency: config.currency, + offers: config.offers.map( + ({ providerProductId: _providerProductId, ...offer }) => offer, + ), + checkoutAvailable: + config.deploymentMode === "cloud" && config.offers.length === 4, + }; +} + +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 || "http://localhost:3000"; + const params = new URLSearchParams({ + tab: "plan", + billing: "confirming", + organization: organizationPublicId, + }); + return `${new URL(webClient).origin}/organizations?${params.toString()}`; +} + +export async function createOrganizationCheckout(input: { + organizationId: string; + payerId: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + pendingTeamName?: string; + grant?: BillingActionGrant; +}) { + 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.offerKey === 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.payerId)) + .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 [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(); + 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(billingTrialClaims.trialKey, "pro_month"), + inArray( + billingTrialClaims.verifiedEmailFingerprint, + fingerprints, + ), + ), + ) + .limit(1); + const [existingUser] = await db + .select({ status: billingTrialClaims.status }) + .from(billingTrialClaims) + .where( + and( + eq(billingTrialClaims.userId, identity.id), + eq(billingTrialClaims.trialKey, "pro_month"), + ), + ) + .limit(1); + const claimed = existing ?? existingUser; + if (!claimed || claimed.status === "released") { + trialDays = offer.trialDays; + } + } + + try { + 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 || identity.email, + }, + offerKey: offer.catalogKey, + catalogRevision: input.catalogRevision, + returnUrl: returnUrl(organization.organizationId), + trialDays, + applicationFields: { + pendingTeamName: input.pendingTeamName ?? null, + }, + }); + 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: result.checkoutUrl, + expiresAt: result.attempt.expiresAt.toISOString(), + }; + } catch (error) { + throw mapCheckoutWorkflowError(error, errorDetails(config)); + } +} + +/** 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 billing = getBillingEngine(); + const attempt = await billing.store.findCheckoutById(attemptId); + if (!attempt || attempt.status !== "creating" || attempt.expiresAt <= now) { + return false; + } + 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: { + payerId: string; + organizationName: string; + teamName: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + grant?: BillingActionGrant; +}) { + 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.payerId, + 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" + ) { + throw new BillingCheckoutError("pending_organization_exists", 409); + } else { + throw error; + } + } + try { + const checkout = await createOrganizationCheckout({ + organizationId: organization.id, + 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) { + 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/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 new file mode 100644 index 0000000..ac8f016 --- /dev/null +++ b/apps/api/src/billing/entitlements.test.ts @@ -0,0 +1,242 @@ +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, + billingPlanStates, + billingSubscriptions, + 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({ + offerKey: "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", + payerId: account.id, + providerCustomerId: `cus_${crypto.randomUUID()}`, + idempotencyKey: `customer:dodo:${account.id}`, + status: "active", + }) + .returning(); + const [subscription] = await tdb + .insert(billingSubscriptions) + .values({ + billableEntityId: organization.id, + billingCustomerId: customer.id, + payerId: account.id, + provider: "dodo", + providerSubscriptionId: `sub_${crypto.randomUUID()}`, + providerProductId: price.providerProductId, + billingPriceEntryId: price.id, + catalogRevision: 1, + offerKey: "pro_month", + plan: "pro", + billingInterval: "month", + status: "cancelled", + paidThroughAt: status.paidThroughAt, + cancelAtPeriodEnd: status.cancelAtPeriodEnd, + isEntitlementSource: status.isEntitlementSource ?? true, + }) + .returning(); + await tdb + .update(billingPlanStates) + .set({ + plan: "pro", + activeSubscriptionId: subscription.id, + }) + .where(eq(billingPlanStates.billableEntityId, 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(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, 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(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organization.id)); + const [row] = await tdb + .select() + .from(billingSubscriptions) + .where(eq(billingSubscriptions.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..4f3f90b --- /dev/null +++ b/apps/api/src/billing/entitlements.ts @@ -0,0 +1,923 @@ +import { and, count, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCheckoutAttempts, + contacts, + billingPlanStates, + billingSubscriptions, + 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(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) + .limit(1) + .for("update"); + if (existing) return existing; + const [created] = await tx + .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(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, 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(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, organizationId)) + .limit(1); + + let subscription: SubscriptionLike | null = null; + if (state?.activeSubscriptionId) { + const [row] = await db + .select() + .from(billingSubscriptions) + .where(eq(billingSubscriptions.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.billableEntityId, 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(billingSubscriptions) + .where(eq(billingSubscriptions.id, state.activeSubscriptionId)) + .limit(1) + : []; + const [pending] = await tx + .select({ id: billingCheckoutAttempts.id }) + .from(billingCheckoutAttempts) + .where( + and( + eq(billingCheckoutAttempts.billableEntityId, 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(billingPlanStates) + .set({ + rampStage: stage, + rampCleanStageDays: cleanDays, + rampEvaluatedAt: now, + updatedAt: now, + }) + .where(eq(billingPlanStates.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: billingPlanStates.id, + rampStage: billingPlanStates.rampStage, + rampCleanStageDays: billingPlanStates.rampCleanStageDays, + rampEvaluatedAt: billingPlanStates.rampEvaluatedAt, + }) + .from(billingPlanStates) + .where(eq(billingPlanStates.billableEntityId, 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..78c79e7 --- /dev/null +++ b/apps/api/src/billing/notifications.ts @@ -0,0 +1,154 @@ +import { and, eq } from "drizzle-orm"; +import { createTransport } from "nodemailer"; +import { db } from "../db/client"; +import { + organizationMembers, + billingSubscriptions, + 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({ + payerId: billingSubscriptions.payerId, + }) + .from(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.billableEntityId, organizationId), + eq(billingSubscriptions.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?.payerId) { + const [manager] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, subscription.payerId)) + .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..2f2a624 --- /dev/null +++ b/apps/api/src/billing/plan-change.test.ts @@ -0,0 +1,255 @@ +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 { + billingCatalogRevisionItems, + billingCatalogRevisions, + billingPriceEntries, + billingProviderCustomers, + billingPlanStates, + billingSubscriptions, +} 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(); + process.env.WEB_CLIENT = "http://localhost:3000"; + catalogMocks.getBillingProvider.mockReturnValue({ + provider: "dodo", + capabilities: { + planChanges: true, + intervalChanges: true, + 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); +}); + +async function seedActiveSubscription() { + const { account, organization } = await seedTeamAndContact(tdb); + const [currentPrice] = await tdb + .insert(billingPriceEntries) + .values({ + offerKey: "pro_month", + plan: "pro", + billingInterval: "month", + currency: "USD", + amountMinor: 4900, + provider: "dodo", + providerProductId: "pdt_pro_month", + }) + .returning(); + const [targetPrice] = await tdb + .insert(billingPriceEntries) + .values({ + offerKey: "business_month", + plan: "business", + billingInterval: "month", + currency: "USD", + amountMinor: 19900, + provider: "dodo", + 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", + payerId: account.id, + providerCustomerId: `cus_${crypto.randomUUID()}`, + idempotencyKey: `customer:dodo:${account.id}`, + status: "active", + }) + .returning(); + const [subscription] = await tdb + .insert(billingSubscriptions) + .values({ + billableEntityId: organization.id, + billingCustomerId: customer.id, + payerId: account.id, + provider: "dodo", + providerSubscriptionId: `sub_${crypto.randomUUID()}`, + providerProductId: currentPrice.providerProductId, + billingPriceEntryId: currentPrice.id, + catalogRevision: 1, + offerKey: "pro_month", + plan: "pro", + billingInterval: "month", + status: "active", + isEntitlementSource: true, + }) + .returning(); + await tdb + .update(billingPlanStates) + .set({ + plan: "pro", + activeSubscriptionId: subscription.id, + }) + .where(eq(billingPlanStates.billableEntityId, organization.id)); + catalogMocks.requireActiveCatalog.mockResolvedValue({ + revision: { revision: 1, status: "active" }, + items: [ + { offerKey: "pro_month", price: currentPrice }, + { offerKey: "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(billingPlanStates) + .set({ plan: "free", activeSubscriptionId: null }) + .where(eq(billingPlanStates.billableEntityId, organization.id)); + + await expect( + createOrganizationPlanChange({ + organizationId: organization.id, + actorId: 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(billingSubscriptions) + .set({ status: "cancelled", cancelAtPeriodEnd: true }) + .where(eq(billingSubscriptions.id, subscription.id)); + + await expect( + createOrganizationPlanChange({ + organizationId: organization.id, + actorId: 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..0bec4e7 --- /dev/null +++ b/apps/api/src/billing/plan-change.ts @@ -0,0 +1,336 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingPlanChangeAttempts, + billingPlanStates, + billingSubscriptions, + organizations, +} from "../db/schema"; +import { decryptBillingValue } from "./crypto"; +import { readBillingConfig, getBillingOffer } from "./catalog"; +import { requireActiveCatalog, verifyCheckoutOffer } from "./catalog-store"; +import { getBillingProvider } from "./provider-registry"; +import { getBillingEngine, preconsumedGrant } from "./engine"; +import { BillingWorkflowError } from "@codelitdev/billing/core"; +import type { BillingActionGrant } from "@codelitdev/billing/workflows"; + +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 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; + actorId: string; + plan: "pro" | "business"; + interval: "month" | "year"; + catalogRevision: number; + idempotencyKey?: string; + grant?: BillingActionGrant; +}) { + 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.offerKey === offer.catalogKey, + )?.price; + if (!targetPrice) + throw new BillingPlanChangeError("billing_catalog_unavailable", 503); + + 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 (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, + ); + } + 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 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 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); + } +} + +export async function getOrganizationPlanChange(input: { + organizationId: string; + changeId: string; + userId: string; +}) { + const [row] = await db + .select() + .from(billingPlanChangeAttempts) + .where( + and( + eq( + billingPlanChangeAttempts.billableEntityId, + input.organizationId, + ), + eq(billingPlanChangeAttempts.changeId, input.changeId), + ), + ) + .limit(1); + if (!row) return null; + return responseFor(row, row.actorId === 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..64e3a45 --- /dev/null +++ b/apps/api/src/billing/portal.ts @@ -0,0 +1,71 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { organizations, user } from "../db/schema"; +import { readBillingConfig } from "./catalog"; +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 { + 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 [payer] = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, input.userId)) + .limit(1); + 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 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: 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 new file mode 100644 index 0000000..a5e0c4d --- /dev/null +++ b/apps/api/src/billing/provider-contract.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..5fad466 --- /dev/null +++ b/apps/api/src/billing/provider-registry.ts @@ -0,0 +1,46 @@ +import { FakeBillingProvider } from "@codelitdev/billing/providers"; +import type { BillingProviderAdapter } from "@codelitdev/billing/providers"; +import { readBillingConfig } from "./catalog"; +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(); + +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 = createSendLitDodoProvider(); + 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; +} + +export function resetBillingProviderInstances(): void { + instances.clear(); +} diff --git a/apps/api/src/billing/provider.ts b/apps/api/src/billing/provider.ts new file mode 100644 index 0000000..332f0d0 --- /dev/null +++ b/apps/api/src/billing/provider.ts @@ -0,0 +1,32 @@ +/** + * Provider-neutral billing contract. Adapter implementations live in + * `@codelitdev/billing`; SendLit only composes them with env, org policy, + * and persistence. + */ +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; + }; 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..ad79d3a --- /dev/null +++ b/apps/api/src/billing/providers/dodo/index.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { dodoOptionsFromEnv } from "./index"; + +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_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(), + }); + 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(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 new file mode 100644 index 0000000..634dcad --- /dev/null +++ b/apps/api/src/billing/providers/dodo/index.ts @@ -0,0 +1,55 @@ +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 { + apiKey, + environment: + environment === "live_mode" || environment === "test_mode" + ? environment + : "test_mode", + webhookSecrets, + clock, + }; +} + +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 new file mode 100644 index 0000000..5535375 --- /dev/null +++ b/apps/api/src/billing/providers/fake/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +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.controls.outage = true; + 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" }); + adapter.controls.outage = false; + 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..a594e60 --- /dev/null +++ b/apps/api/src/billing/providers/fake/index.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..f748c26 --- /dev/null +++ b/apps/api/src/billing/reconciliation.ts @@ -0,0 +1,389 @@ +import { + and, + eq, + gt, + isNotNull, + isNull, + lte, + lt, + or, + inArray, +} from "drizzle-orm"; +import { db } from "../db/client"; +import { + billingCheckoutAttempts, + billingPlanChangeAttempts, + billingPriceEntries, + billingSubscriptions, + organizations, + planSendReservations, + sendingDomains, +} from "../db/schema"; +import { readBillingConfig } from "./catalog"; +import { + recordRequestedCatalogRevision, + verifyCatalogAgainstProvider, +} from "./catalog-store"; +import { recordBillingMetric } from "./metrics"; +import { providerErrorSummary } from "./provider"; +import { expireCancelledSubscriptionEntitlements } from "./webhooks/processor"; +import { resumeOrganizationCheckoutAttempt } from "./checkout"; +import { settleExpiredSendReservation } from "./entitlements"; +import { evaluateAllTeamReputations } from "./reputation"; +import { verifySendingDomain } from "./domains"; +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 { + void now; + const { getBillingEngine } = await import("./engine.js"); + await getBillingEngine().runWebhookInboxBatch({ + workerId: `inbox-${process.pid}`, + limit: 100, + }); +} + +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; + const { getBillingEngine } = await import("./engine.js"); + const billing = getBillingEngine(); + try { + await recordRequestedCatalogRevision(config); + await verifyCatalogAgainstProvider(config); + } 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 expired = await billing.runDeadlineBatch({ limit: 100 }); + if (expired) + logger.info( + { billing_deadline_processed: expired }, + "billing deadline batch applied", + ); + 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.billableEntityId, + 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(billingSubscriptions) + .where( + and( + inArray(billingSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + "cancelled", + ]), + or( + isNull(billingSubscriptions.lastReconciledAt), + lt(billingSubscriptions.lastReconciledAt, cutoff), + ), + ), + ) + .limit(100); + 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 + // original result; a scheduled change may safely remain pending until its + // next-billing snapshot arrives. + const planChanges = await db + .select({ + attempt: billingPlanChangeAttempts, + subscription: billingSubscriptions, + price: billingPriceEntries, + }) + .from(billingPlanChangeAttempts) + .innerJoin( + billingSubscriptions, + eq( + billingSubscriptions.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 } of planChanges) { + await billing.enqueueJob({ + provider: attempt.provider, + planChangeAttemptId: attempt.id, + }); + } + 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) => { + 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..42d0d3a --- /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 { + billingPlanStates, + 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( + billingPlanStates, + eq(billingPlanStates.billableEntityId, 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..ec3fb05 --- /dev/null +++ b/apps/api/src/billing/routes.ts @@ -0,0 +1,541 @@ +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, + billingPlanStates, + billingSubscriptions, +} 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, + readBillingActionToken, + requireBillingAction, + type BillingAction, +} from "./security"; +import type { BillingActionGrant } from "@codelitdev/billing/workflows"; + +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({ + payerId: 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(({ offerKey, price }) => ({ + catalogKey: offerKey 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 === offerKey, + )?.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(billingPlanStates) + .where( + eq( + billingPlanStates.billableEntityId, + authorization.organization.id, + ), + ) + .limit(1); + const [state] = planState?.activeSubscriptionId + ? await db + .select() + .from(billingSubscriptions) + .where( + eq( + billingSubscriptions.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.billableEntityId, + 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?.payerId ?? 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 ready = await readBillingActionToken( + req, + req.res, + params.organizationId, + ); + 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, + payerId: (req as any).userId, + plan: body.plan, + interval: body.interval, + catalogRevision: body.catalogRevision, + grant, + }); + 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 ready = await readBillingActionToken( + req, + req.res, + params.organizationId, + ); + 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) { + 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 ready = await readBillingActionToken( + req, + req.res, + params.organizationId, + ); + 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, + 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, + 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..b21e638 --- /dev/null +++ b/apps/api/src/billing/security.test.ts @@ -0,0 +1,165 @@ +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 { organizations } from "../db/schema"; +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" }, + }); + }); + + 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 new file mode 100644 index 0000000..f8742b4 --- /dev/null +++ b/apps/api/src/billing/security.ts @@ -0,0 +1,318 @@ +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 { organizations, 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; +} + +/** 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 { + 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 + ) { + 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); + 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 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, + 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..d342b93 --- /dev/null +++ b/apps/api/src/billing/webhook-retry.ts @@ -0,0 +1,4 @@ +export { + billingWebhookRetry, + DEFAULT_WEBHOOK_MAX_ATTEMPTS as BILLING_WEBHOOK_MAX_ATTEMPTS, +} from "@codelitdev/billing/core"; 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..78ae2e9 --- /dev/null +++ b/apps/api/src/billing/webhooks/processor.ts @@ -0,0 +1,648 @@ +import { and, eq, inArray, lte, lt, or, sql } from "drizzle-orm"; +import { db } from "../../db/client"; +import { + billingCheckoutAttempts, + billingPlanChangeAttempts, + billingPriceEntries, + billingProviderCustomers, + billingTrialClaims, + billingWebhookEvents, + organizationAuditEvents, + 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"; +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"; + +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; + * 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(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.provider, event.provider), + eq( + billingSubscriptions.providerSubscriptionId, + snapshot.providerSubscriptionId, + ), + ), + ) + .limit(1) + .for("update"); + let attempt = null as + typeof billingCheckoutAttempts.$inferSelect | null; + const checkoutAttemptId = checkoutAttemptIdFromSnapshot(snapshot); + if (!existing && checkoutAttemptId) { + const [row] = await tx + .select() + .from(billingCheckoutAttempts) + .where(eq(billingCheckoutAttempts.attemptId, checkoutAttemptId)) + .limit(1) + .for("update"); + attempt = row ?? null; + } + const organizationId = + existing?.billableEntityId ?? attempt?.billableEntityId; + const billingCustomerId = + existing?.billingCustomerId ?? attempt?.billingCustomerId; + const billingManagerUserId = existing?.payerId ?? attempt?.payerId; + 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(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.providerOccurredAt ?? snapshot.observedAt; + if ( + existing?.providerOccurredAt && + snapshot.providerOccurredAt && + existing.providerOccurredAt > snapshot.providerOccurredAt + ) + return; + if (existing) { + const decision = decideSubscriptionTransition( + existing.status as CanonicalSubscriptionStatus, + snapshot.status, + ); + if (!decision.allowed) { + throw new Error("billing_invalid_subscription_transition"); + } + } + const catalogKey = price.offerKey; + const now = new Date(); + const retainsPaidEntitlement = packageRetainsPaidEntitlement( + snapshot, + now, + ); + 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 = { + billableEntityId: organizationId, + billingCustomerId, + payerId: billingManagerUserId, + provider: event.provider, + providerSubscriptionId: snapshot.providerSubscriptionId, + providerProductId: snapshot.providerProductId, + billingPriceEntryId: price.id, + catalogRevision: + existing?.catalogRevision ?? attempt?.catalogRevision ?? 0, + offerKey: 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, + providerOccurredAt: providerOccurredAt, + lastReconciledAt: new Date(), + updatedAt: new Date(), + } as const; + const [subscription] = existing + ? await tx + .update(billingSubscriptions) + .set(values) + .where(eq(billingSubscriptions.id, existing.id)) + .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 + // supersedes a prior terminal/old source; conflicting active sources + // are rejected by the database partial unique index. + if (shouldProject) { + await tx + .update(billingSubscriptions) + .set({ isEntitlementSource: false, updatedAt: new Date() }) + .where( + and( + eq( + billingSubscriptions.billableEntityId, + organizationId, + ), + eq(billingSubscriptions.isEntitlementSource, true), + ), + ); + if (retainsPaidEntitlement) { + await tx + .update(billingSubscriptions) + .set({ isEntitlementSource: true, updatedAt: new Date() }) + .where(eq(billingSubscriptions.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(billingPlanStates) + .set({ + plan: nextPlan, + activeSubscriptionId: nextSubscriptionId, + firstPaidActivatedAt: + active && !state.firstPaidActivatedAt + ? providerOccurredAt + : state.firstPaidActivatedAt, + projectionVersion: state.projectionVersion + 1, + updatedAt: new Date(), + }) + .where(eq(billingPlanStates.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.payerId, + 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: billingSubscriptions.id, + organizationId: billingSubscriptions.billableEntityId, + }) + .from(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.status, "cancelled"), + eq(billingSubscriptions.isEntitlementSource, true), + lte(billingSubscriptions.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(billingSubscriptions) + .where(eq(billingSubscriptions.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(billingPlanStates) + .where( + eq(billingPlanStates.billableEntityId, row.organizationId), + ) + .limit(1) + .for("update"); + if (!state || state.activeSubscriptionId !== subscription.id) + return false; + await tx + .update(billingSubscriptions) + .set({ isEntitlementSource: false, updatedAt: now }) + .where(eq(billingSubscriptions.id, subscription.id)); + await tx + .update(billingPlanStates) + .set({ + plan: "free", + activeSubscriptionId: null, + projectionVersion: state.projectionVersion + 1, + updatedAt: now, + }) + .where(eq(billingPlanStates.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 | null; + verifiedKeyVersion?: string | null; + correlationMetadata?: { + checkoutAttemptId?: string; + catalogKey?: string; + }; + }; + }; + if (envelope.canonical) { + const canonical = envelope.canonical; + event = { + provider: canonical.provider, + providerEventId: canonical.providerEventId, + eventType: canonical.eventType, + occurredAt: new Date(canonical.occurredAt), + subscriptionId: canonical.subscriptionId ?? null, + verifiedKeyVersion: + canonical.verifiedKeyVersion ?? null, + correlationMetadata: + canonical.correlationMetadata ?? {}, + }; + } + 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..e82db5e --- /dev/null +++ b/apps/api/src/billing/webhooks/routes.ts @@ -0,0 +1,60 @@ +import express, { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { getBillingEngine } from "../engine"; +import { pageBillingAlert, recordWebhookSignatureFailure } from "../alerts"; + +const router = Router(); + +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] ?? ""; + } + try { + 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) { + 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" }); + } + }, +); + +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/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 5b4f050..8518347 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,2007 +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 ('active', 'suspended', '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(), -}); - -/** 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/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..d4dff62 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, + billingPlanStates, + billingSubscriptions, + 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,171 @@ 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({ + offerKey: "pro_month", + plan: "pro", + billingInterval: "month", + currency: "USD", + amountMinor: 4900, + provider: "dodo", + providerProductId: `pdt_${crypto.randomUUID()}`, + }) + .returning(); + await tdb.insert(billingCheckoutAttempts).values({ + attemptId: `bca_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`, + billableEntityId: organization.id, + payerId: owner.id, + provider: "dodo", + catalogRevision: 1, + offerKey: "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({ + offerKey: "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", + payerId: ownerId, + providerCustomerId: `cus_${crypto.randomUUID()}`, + idempotencyKey: `customer:dodo:${ownerId}`, + status: "active", + }) + .returning(); + const [subscription] = await tdb + .insert(billingSubscriptions) + .values({ + billableEntityId: organizationId, + billingCustomerId: customer.id, + payerId: ownerId, + provider: "dodo", + providerSubscriptionId: `sub_${crypto.randomUUID()}`, + providerProductId: price.providerProductId, + billingPriceEntryId: price.id, + catalogRevision: 1, + offerKey: "pro_month", + plan: "pro", + billingInterval: "month", + status: input.status, + paidThroughAt: input.paidThroughAt, + cancelAtPeriodEnd: input.cancelAtPeriodEnd, + isEntitlementSource: true, + }) + .returning(); + await tdb + .update(billingPlanStates) + .set({ + plan: "pro", + activeSubscriptionId: subscription.id, + }) + .where(eq(billingPlanStates.billableEntityId, 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..237379a 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, + billingPlanStates, + billingSubscriptions, + 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: billingSubscriptions.id }) + .from(billingSubscriptions) + .where(eq(billingSubscriptions.billableEntityId, organizationId)) + .orderBy(asc(billingSubscriptions.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: billingPlanStates, + subscription: billingSubscriptions, + }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .innerJoin( + billingPlanStates, + eq(billingPlanStates.billableEntityId, organizations.id), + ) + .leftJoin( + billingSubscriptions, + and( + eq(billingSubscriptions.billableEntityId, organizations.id), + eq( + billingSubscriptions.id, + billingPlanStates.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.billableEntityId, + 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,123 @@ 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, + actorId: 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, actorId), + ), + ) + .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.billableEntityId, + 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: actorId }, + action: "organization.pending_abandoned", + metadata: {}, + }); + }); } export async function closeOrganization( @@ -122,7 +425,75 @@ 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: billingSubscriptions.id }) + .from(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.billableEntityId, organizationId), + or( + inArray(billingSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + ]), + and( + eq(billingSubscriptions.status, "cancelled"), + eq(billingSubscriptions.cancelAtPeriodEnd, true), + gt(billingSubscriptions.paidThroughAt, new Date()), + ), + ), + ), + ) + .orderBy(asc(billingSubscriptions.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.billableEntityId, + 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 +588,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 +630,51 @@ async function assertNotLastOwner( } } +async function assertBillingManagerRetained( + tx: Parameters[0]>[0], + organizationId: string, + userId: string, +): Promise { + const [subscription] = await tx + .select({ id: billingSubscriptions.id }) + .from(billingSubscriptions) + .where( + and( + eq(billingSubscriptions.billableEntityId, organizationId), + eq(billingSubscriptions.payerId, userId), + or( + inArray(billingSubscriptions.status, [ + "pending", + "trialing", + "active", + "past_due", + ]), + and( + eq(billingSubscriptions.status, "cancelled"), + eq(billingSubscriptions.cancelAtPeriodEnd, true), + gt(billingSubscriptions.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 +687,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 +720,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 +742,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 +773,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 +876,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..37beacd 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,21 @@ 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.billingReconciliationJobs); + await db.delete(schema.billingPlanChangeAttempts); + await db.delete(schema.billingCheckoutAttempts); + 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); + 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 +123,12 @@ export async function seedTeamAndContact( await db.insert(schema.organizationDeliveryPolicies).values({ organizationId: organization.id, }); + await db.insert(schema.billingPlanStates).values({ + billableEntityId: organization.id, + plan: "free", + rampStage: 0, + rampCleanStageDays: 0, + }); 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.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 810ef2c..fcab44e 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,60 @@ 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") { + // 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") { + throw new ApiError(401, "recent_authentication_required"); + } 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 +916,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 +938,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 +1330,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..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) @@ -291,6 +294,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 @@ -1083,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: @@ -5122,6 +5137,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==} @@ -9298,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 @@ -13022,6 +13080,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: 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