diff --git a/.env.example b/.env.example index 5f8ed50..efc1aa6 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,38 @@ PIXEL_SIGNING_SECRET= # Generate this one with: openssl rand -base64 32 ESP_CREDENTIALS_ENCRYPTION_KEY= -# The initial account and its default team are created by the one-shot init -# service. Its API key is shown exactly once in `docker compose logs init`. -SUPER_ADMIN_EMAIL=admin@example.com +# The one-shot init service finds or creates this initial organization owner. +# This selects an ordinary organization owner; it does not grant global admin. +BOOTSTRAP_ORGANIZATION_OWNER_EMAIL=admin@example.com + +# Optional headless integration keys. Generate distinct sl_org_live_... keys +# and keep both values in this server-side .env file; never expose them to a +# browser or mobile client. +BOOTSTRAP_DELIVERY_SETUP_API_KEY= +BOOTSTRAP_TEAM_PROVISIONING_API_KEY= + +# 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. @@ -35,3 +64,10 @@ ENABLE_TRUST_PROXY=false MEDIALIT_APIKEY= MEDIALIT_SERVER=https://api.medialit.cloud MAX_UPLOAD_SIZE=10485760 + +# Optional: PostHog for the API (errors/events/logs) and the web dashboard +# (identify + session recording). Unset disables telemetry. The web app reads +# these at container start — they are not compiled into the Next.js image. +POSTHOG_API_KEY= +POSTHOG_HOST=https://us.i.posthog.com +DEPLOY_ENV= 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/ARCHITECTURE.md b/ARCHITECTURE.md index 6a9d526..11d55b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -229,13 +229,15 @@ Phase 5 — **done**: consumer's own tenants may share an owner email (which would otherwise incorrectly merge them into one team). Provisioning creates no account or membership; the organization owns the resulting team. - - `src/bootstrap.ts`: a _separate_, boot-time-only convenience directly - ported from MediaLit's `createAdminUser()` — if `SUPER_ADMIN_EMAIL` is - set and no account exists for it yet, creates one (with its default team - - key) and logs the key once. Useful for local dev/self-hosting a single - instance; not a substitute for `/provisioning/teams`, which a multi-tenant - consumer needs in order to provision many teams over the running lifetime - of the app, not just once at boot. + - `src/bootstrap.ts`: a _separate_, boot-time-only convenience — when + `BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` is set, it finds or creates that + ordinary initial owner, ensures their default organization/team, and + registers the configured delivery and team provisioning keys from the + environment. It stores only hashes and never logs secrets. This is + useful for local/self-hosted + installs, but is not a substitute for `/provisioning/teams`, which a + multi-tenant consumer needs to provision many teams over the running + lifetime of the app. - `apps/web`: a `/dashboard/teams` page (list teams, create new ones, switch — a plain form POST to `/api/team/switch` sets a `sendlit_team_id` cookie the BFF proxy forwards as `X-Sendlit-Team-Id` — @@ -253,9 +255,11 @@ Phase 5 — **done**: contacts, sending identity, and quota, regardless of how many people or integration keys touch it. -Validated end-to-end against a live Postgres + Redis + Mailpit stack: booted -with `SUPER_ADMIN_EMAIL` set and confirmed the account/team/key were created -and logged; created a contact via that key; provisioned a second team via +The original organization implementation was validated end-to-end against a +live Postgres + Redis + Mailpit stack: booted with +`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` set and confirmed the +organization-owner/team/key were created; created a contact via that key; +provisioned a second team via `POST /provisioning/teams` (simulating a CourseLit tenant) and created a contact with the _same_ email address under it — confirmed both contacts exist independently, one per team, with no collision; confirmed re-provisioning diff --git a/README.md b/README.md index a6c8e73..d6b2fb4 100644 --- a/README.md +++ b/README.md @@ -35,22 +35,88 @@ 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 the local dependencies with the dedicated Compose file, then run the API +and web app on the host: + +```sh +docker compose -f docker-compose.local.yml up -d +docker compose -f docker-compose.local.yml ps +``` + +The local stack provides Postgres on `localhost:5434`, Redis on +`localhost:6380`, and Mailpit SMTP/UI on `localhost:1027`/`localhost:8027`. +Those defaults avoid collisions with the CourseLit local stack (Postgres +`5432`, Mailpit `1026`/`8026`) and the FrontLit local stack (Postgres `5433`, +Redis `6379`, Mailpit `1025`/`8025`). You can override the SendLit host ports +with `SENDLIT_LOCAL_POSTGRES_PORT`, `SENDLIT_LOCAL_REDIS_PORT`, +`SENDLIT_LOCAL_MAILPIT_SMTP_PORT`, and `SENDLIT_LOCAL_MAILPIT_HTTP_PORT` in +the shell or root `.env`; if you override them, update the matching database, +Redis, and SMTP ports in `apps/api/.env` too. + +The `apps/api/.env.example` database, Redis, and platform SMTP settings match +these local services. Mailpit captures platform emails such as sign-in OTPs; +campaign and ESP test emails use the ESP configured in SendLit. When testing an +SMTP ESP against local Mailpit from the host-run API, use `127.0.0.1:1027`. +Open the Mailpit UI at . + +Then push the development schema and start the apps as described below. To stop +the dependencies without deleting local database/queue data: + +```sh +docker compose -f docker-compose.local.yml down +``` + +To intentionally delete the local Postgres and Redis data as well, use +`docker compose -f docker-compose.local.yml down -v`. + +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 The root Compose stack runs PostgreSQL, Redis, the API, and the web dashboard. It also uses a one-shot `init` service to apply database migrations and create -the first account, its default team, and a team-scoped API key. +or find the initial organization owner, their default team, and any configured +organization API keys. ```sh cp .env.example .env @@ -59,11 +125,17 @@ docker compose up --build -d docker compose logs init ``` -Set `SUPER_ADMIN_EMAIL` before the first start. The `init` logs contain the -initial API key exactly once; save it in a password manager and use it as the -`x-sendlit-apikey` header. If it is lost, create a replacement in the dashboard -or through the authenticated API. Open the dashboard at `WEB_CLIENT` (by -default, `http://localhost:3000`) and API documentation at `API_PUBLIC_URL/docs`. +`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` selects an ordinary initial organization +owner; it does not create a special global-admin role. To integrate +headlessly, set `BOOTSTRAP_DELIVERY_SETUP_API_KEY` and `BOOTSTRAP_TEAM_PROVISIONING_API_KEY` in +`.env`. Bootstrap hashes both keys and never logs them. +See the [Headless organization setup](./apps/docs/content/docs/developers/headless-provisioning.mdx) +for the complete REST sequence, scopes, rotation, and migration from the old +log-generated key. The plain Markdown guide is at +[`apps/docs/integrations/headless-provisioning.md`](./apps/docs/integrations/headless-provisioning.md). + +Open the dashboard at `WEB_CLIENT` (by default, +`http://localhost:3000`) and API documentation at `API_PUBLIC_URL/docs`. For an internet-facing deployment, set `API_PUBLIC_URL`, `WEB_CLIENT`, `PROTOCOL=https`, and `DOMAIN` to the public values before the first start. diff --git a/apps/api/.env.example b/apps/api/.env.example index 84d10f8..5cf528e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,13 +1,67 @@ # Postgres connection string -DB_CONNECTION_STRING=postgres://sendlit:sendlit@localhost:5432/sendlit +DB_CONNECTION_STRING=postgres://sendlit:sendlit@localhost:5434/sendlit # Redis (used by BullMQ for mail sending + sequence delivery) REDIS_HOST=localhost -REDIS_PORT=6379 +REDIS_PORT=6380 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 +# Optional admin paging for billing SLOs (comma-separated). Unset disables email alerts. +# 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 @@ -50,23 +104,28 @@ SUPPRESSION_HASH_KEY= # ESP in settings. # EMAIL_HOST is required in production. EMAIL_USER/EMAIL_PASS are optional # (leave empty for auth-less sinks like Mailpit). -EMAIL_HOST= -EMAIL_PORT=587 +# Local docker-compose.local.yml Mailpit defaults. Override for deployed envs. +EMAIL_HOST=127.0.0.1 +EMAIL_PORT=1027 EMAIL_USER= EMAIL_PASS= EMAIL_FROM= -# Optional: on boot, if set and no account exists for this email yet, creates -# one (with its default team + API key) and logs the key once — a dev/ -# self-host convenience, mirroring MediaLit's admin-bootstrap script. See -# src/bootstrap.ts. -SUPER_ADMIN_EMAIL= +# Optional: on boot, ensure this initial organization owner and their default +# organization/team exist. This selects an ordinary owner, not a global-admin +# role. Both configured organization API keys are read from environment +# variables, hashed, and never logged. +BOOTSTRAP_ORGANIZATION_OWNER_EMAIL= +BOOTSTRAP_DELIVERY_SETUP_API_KEY= +BOOTSTRAP_TEAM_PROVISIONING_API_KEY= # Optional: PostHog error tracking, product analytics and log shipping. # When POSTHOG_API_KEY is set, all pino logs (src/services/log.ts) at # POSTHOG_LOG_LEVEL and above are also shipped to PostHog via OTLP, and # errors/events are captured via src/observability/posthog.ts. When unset, # telemetry is fully disabled and logs go to stdout only. +# apps/web uses the same POSTHOG_API_KEY / POSTHOG_HOST at request time +# (not NEXT_PUBLIC_*) for identify and session recording. POSTHOG_API_KEY= POSTHOG_HOST=https://us.i.posthog.com # Minimum level shipped to PostHog (default info). stdout level is LOG_LEVEL. 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/README.md b/apps/api/README.md index e6b182b..c2f6c04 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -16,13 +16,18 @@ email provider configuration. receipt inbox, canonical delivery events, and a per-workspace suppression list enforced on every send path. - Open and click tracking, plus unsubscribe handling. -- Better Auth session login, OAuth2 bearer-token authentication, and - team-scoped API key authentication. +- Better Auth session login, OAuth2 bearer-token authentication, team-scoped + API keys, and scoped organization API keys. - An MCP server exposing the same core capabilities for API/MCP clients. ## Running Locally -1. Start Postgres and Redis. +1. Start the local dependencies from the repository root: + + ```sh + docker compose -f docker-compose.local.yml up -d + ``` + 2. Copy `.env.example` to `.env` and fill in the required values. 3. Push the database schema: @@ -36,8 +41,10 @@ email provider configuration. pnpm --filter @sendlit/api dev ``` -The server listens on `PORT` from `.env` (`4000` in `.env.example`; `80` if -unset). +The server listens on `PORT` from `.env` (`5000` in `.env.example`; `80` if +unset). The local Compose file publishes Postgres on `5434`, Redis on `6380`, +and Mailpit on SMTP/UI ports `1027`/`8027`; the example environment is set to +use them. These ports are distinct from the CourseLit and FrontLit local stacks. ## Database Migrations In Docker @@ -51,7 +58,7 @@ The root self-hosted deployment runs this through a Compose `init` service: ```sh cp .env.example .env -# Fill in the required secrets and SUPER_ADMIN_EMAIL. +# Fill in the required secrets and BOOTSTRAP_ORGANIZATION_OWNER_EMAIL. docker compose up --build -d docker compose logs init ``` @@ -60,8 +67,9 @@ Startup order: 1. Postgres starts and passes its health check. 2. Redis starts and passes its health check. -3. `init` runs `node apps/api/dist/db/migrate.js`, then creates the configured - super-admin account, default team, and one-time API key. +3. `init` runs `node apps/api/dist/db/migrate.js`, ensures the configured + initial organization owner and default organization/team exist, then + registers configured organization keys from `.env`. 4. The init container exits successfully. 5. `api` starts. 6. `web` starts. @@ -113,15 +121,18 @@ SendLit supports three authentication modes: authorization-server metadata at `/.well-known/oauth-authorization-server` and OIDC metadata at `/.well-known/openid-configuration`. -- **API keys** for server-to-server, REST, and MCP clients. API keys are scoped - to one team and are sent with the `x-sendlit-apikey` header. +- **Team API keys** for server-to-server REST and MCP clients; send them with + the `x-sendlit-apikey` header. **Organization API keys** are scoped to one + organization and sent as Bearer credentials for provisioning and selected + organization REST operations. Session and OAuth requests resolve the active team through the `X-Sendlit-Team-Id` header. If the account belongs to exactly one team, the header may be omitted. API keys are stored hashed and cannot be recovered after creation. If a key is -lost, create a new one and revoke the old key. +lost, create a new one and revoke the old key. Self-host bootstrap reads both +configured keys from the environment; neither is printed to logs. When the API runs behind the web BFF or another reverse proxy, set `ENABLE_TRUST_PROXY=true` so auth endpoint rate limits can use the real client @@ -155,24 +166,30 @@ curl http://localhost:5000/contacts \ The `X-Sendlit-Team-Id` header is optional only when the account belongs to exactly one team. -## Bootstrap API Key - -For local development or self-hosted installs, set `SUPER_ADMIN_EMAIL` before -starting the API. On startup, if no account exists for that email, SendLit -creates: - -- the account; -- its default team; -- a default team-scoped API key. - -The plaintext API key is logged once with the `Super admin account created` -message. After that, only the hash is stored. Copy the key from the first -startup logs (`docker compose logs`, or the API process logs) and use it as -`x-sendlit-apikey` for REST or MCP requests. - -If the account already exists, bootstrap does nothing and cannot re-print the -key. Create or rotate keys through the dashboard, `POST /teams/:teamId/keys`, -or the MCP `create_api_key` tool. +## Headless organization setup + +`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` selects the initial owner; it does not +grant a special global-admin role. Bootstrap also runs when this user already +exists, reuses their default organization, and registers configured +organization API keys from `BOOTSTRAP_DELIVERY_SETUP_API_KEY` and +`BOOTSTRAP_TEAM_PROVISIONING_API_KEY`. Both values are set in `.env`. Bootstrap stores +only their hashes, never logs the secrets, and fails closed if a key is +revoked, expired, belongs to another organization, or has different scopes. + +Use separate keys: the short-lived delivery setup key has +`organization:read`, `esps:read`, `esps:manage`, `delivery:read`, and +`delivery:manage`; the runtime team provisioning key has only +`organization:read`, `teams:provision`, and `teams:read`. `delivery:manage` +also authorizes quota and team-delivery policy fields, so revoke it after +one-time setup if it is no longer needed. Compose passes both keys only to +`init` and clears them from the long-running API and web containers. Configure +the team key in the consuming integration's server-side environment. + +Follow the [Headless organization setup guide](../docs/content/docs/developers/headless-provisioning.mdx) +for Compose configuration, REST examples, reruns, rotation, old-key migration, and the +team grant/default inheritance flow. The API's machine-readable contract is +available at `/openapi.json`. The raw Markdown guide is +[`../docs/integrations/headless-provisioning.md`](../docs/integrations/headless-provisioning.md). ## Teams And Tenancy @@ -189,9 +206,10 @@ multi-tenant consumers, such as CourseLit, to find or create one SendLit team per external tenant. It requires a scoped organization API key as a Bearer token; it is not authenticated by an end-user OAuth session or team API key. -`SUPER_ADMIN_EMAIL` is only a boot-time convenience for the first local or -self-hosted account. It is not the provisioning mechanism for multi-tenant -consumers. +`BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` selects the initial local or self-hosted +organization owner; it is not the provisioning mechanism for multi-tenant +consumers. Integrations use `/provisioning/teams` with a narrowly scoped +organization key. ## Architecture 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/apikey-management.md b/apps/api/docs/apikey-management.md index 608f768..7d4d9d1 100644 --- a/apps/api/docs/apikey-management.md +++ b/apps/api/docs/apikey-management.md @@ -5,8 +5,9 @@ see `apps/docs/content/docs/developers/authentication.mdx`. ## Key format -A key is `sl_live_` followed by 32 CSPRNG bytes encoded as base64url (43 -chars, 256 bits of entropy), generated in `src/apikey/secret.ts`: +Team keys use `sl_live_`; organization keys use `sl_org_live_`. Each prefix is +followed by 32 CSPRNG bytes encoded as base64url (43 chars, 256 bits of +entropy), generated in `src/apikey/secret.ts`: ``` sl_live_3xKf9q2mVbA7cD1eF8gH4iJ6kL0nP5rS9tU2wX7yZ4o @@ -21,7 +22,8 @@ aesthetics: ## Storage: hashed, never plaintext -The `api_keys` table (`src/db/schema.ts`) does **not** store the secret. +The team `api_keys` and organization-key tables (`src/db/schema.ts`) do **not** +store the secret. It stores: | column | contents | @@ -36,9 +38,11 @@ A plain SHA-256 (not bcrypt/argon2) is deliberate: the secret is already 256 bits of CSPRNG output, so brute-forcing the hash is infeasible, and a slow KDF would only add latency to every authenticated request. -Consequence: **the plaintext secret exists only in the response that creates -the key.** It can never be re-read, re-listed, or recovered — only revoked -and replaced. +For API-generated keys, the plaintext secret exists only in the create +response. It can never be re-read, re-listed, or recovered—only revoked and +replaced. Configured self-host keys are supplied and retained by the operator +through `.env`; SendLit stores only their hashes and does not return the +values. ## Scoping @@ -61,8 +65,10 @@ secret surfaces exactly once, through whichever surface created it: actually created the team** (provisioning is idempotent per `externalId`; repeat calls return the team with no key, since the hash can't be reversed — the consumer must persist it on first provision); -- boot-time super admin (`src/bootstrap.ts`) → logged once so an operator can - grab it from `docker compose logs`. +- self-host bootstrap keys supplied through `.env` → their values are hashed + into organization-key rows and never logged or returned by bootstrap. See + the + [headless provisioning guide](../../docs/integrations/headless-provisioning.md). `createTeam` (`src/team/queries.ts`) can also mint a "Default" key as part of creating the team, via `withDefaultApiKey: true` — its one-time secret @@ -70,7 +76,7 @@ propagates up as `defaultApiKeySecret` on the returned team, and from `createAccount` as well (which forwards its own `withDefaultApiKey` param). This defaults to `false` and is opt-in, precisely because it's only useful to callers with an actual way to hand that secret to someone: provisioning -(response body) and boot-time super admin (startup log). Dashboard-driven +(response body). Dashboard-driven team creation (signup, `POST /teams`, MCP `create_team`) leaves it `false` — otherwise the key would be minted with no way for the user to ever see its secret, defeating the whole point of a one-time reveal. @@ -93,8 +99,9 @@ key stop working on their next request. ## Rules of thumb when touching this code -- Never persist or log the plaintext secret outside the documented - create-time surfaces above. +- Never log the plaintext secret outside the documented create-time surfaces + above. The operator supplies configured self-host keys through `.env`; + SendLit only hashes them and never logs or returns them. - Never return `key_hash` in any API/MCP response. - New response shapes for keys should build on `apiKeySchema` / `createdApiKeySchema` in `packages/api-contract/src/schemas/teams.ts`, and diff --git a/apps/api/docs/headless-provisioning.md b/apps/api/docs/headless-provisioning.md new file mode 100644 index 0000000..c625377 --- /dev/null +++ b/apps/api/docs/headless-provisioning.md @@ -0,0 +1,66 @@ +## Headless provisioning + +The goal is a repeatable, headless integration path: SendLit can be initialized without extracting a generated key from logs or using a human dashboard session, and CourseLit can keep provisioning teams without holding delivery-administration privileges. + +### 1. Add the missing organization API capabilities + +- Add `GET /provisioning/organization`, authorized by `organization:read`. It returns the calling API key’s own organization metadata and public ID, never another organization’s. +- Keep `GET` and `PUT /organizations/:organizationId/delivery-policy`. Permit an organization API key only when it is bound to the URL’s organization and has: + - `delivery:read` for `GET` + - `delivery:manage` for `PUT` +- Preserve owner/admin user-session access. Don’t treat nearby scopes such as ESP or grant management as implicit delivery-policy authority. +- Preserve validation that a default ESP belongs to the organization and is active. Audit key-based policy changes as organization-key actions. +- The ESP test-send route already accepts an explicit recipient. Keep that behavior and test/document it for organization-key calls. + +One permission detail needs to be explicit in the implementation and docs: `delivery:manage` authorizes the policy update schema, which includes more than just the default ESP and automatic grants—it may include quota and team-delivery controls. The one-shot setup key limits the duration of that authority. If automation must be permanently restricted to just the default-delivery fields, that should be a separately scoped capability, not an undocumented restriction on `delivery:manage`. + +### 2. Register separate setup and runtime keys + +Bootstrap should find or create the initial owner’s organization using `BOOTSTRAP_ORGANIZATION_OWNER_EMAIL`, then register the configured keys there—even when the user already exists. It should not create a second organization or silently restore a revoked key. + +| Secret setting | Registered key name | Use | Scopes | Where it should be available | +| ------------------------------------- | ------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| `BOOTSTRAP_DELIVERY_SETUP_API_KEY` | `[Auto-generated] Delivery Setup` | Configure shared delivery once | `organization:read`, `esps:manage`, `delivery:read`, `delivery:manage`; include `esps:read` if the initializer must list ESPs to reuse one | SendLit bootstrap and one-shot setup client | +| `BOOTSTRAP_TEAM_PROVISIONING_API_KEY` | `[Auto-generated] Team Provisioning` | CourseLit’s ongoing team provisioning | `organization:read`, `teams:provision`, `teams:read` | SendLit’s one-shot bootstrap and CourseLit’s runtime | + +Set both keys directly in the deployment `.env`. Bootstrap hashes them into organization API-key records; it never stores or logs the plaintext. It verifies an existing key’s organization, scopes, and revocation state on reruns. A mismatched or revoked key fails closed. Rotation means registering a new key, switching the integration, then explicitly revoking the old one. + +This runtime key is a security improvement, not a prerequisite for team creation. CourseLit currently uses the console-logged bootstrap key, which has broader scopes—including team management, team-key, ESP, and grant-management scopes. It should switch to the narrower configured provisioning key. The old key should remain usable during the switch, then be revoked after the new credential is verified; bootstrap should not revoke it automatically. + +Both keys may live in the deployment `.env`, but Compose should pass them only to the one-shot `init` service and explicitly clear them in the API and web containers. Give the delivery key only to setup automation, and give the provisioning key to the integration runtime. Neither belongs in browser code. + +### 3. Make initial delivery setup one-time and safe to rerun + +The setup workflow should use the delivery key to: + +1. Discover the organization. +2. Reuse the intended ESP or create it. +3. Send a test email with an explicit recipient. +4. Activate the ESP. +5. Set it as the default and enable automatic grants. + +The workflow should not reset an operator’s later delivery choices every time the stack starts. Run it as an explicit setup job or persist a completion marker. ESP names are not unique, so reuse should be based on a stored ESP ID or fail clearly if the name is ambiguous. + +After successful setup, revoke the delivery key if no further delivery-policy automation is needed. CourseLit then uses only the provisioning key to create teams. An end-to-end test should provision a team through the public API and verify that it receives the organization’s delivery grant/default. + +### 4. Update integration documentation as a core deliverable + +Create a canonical integration guide in `apps/docs`, with a raw Markdown version that is easy for both people and agents to consume. Link it from the root self-hosting README and `apps/api/README.md`. Update the existing organization and API-key docs, and the API contract/OpenAPI descriptions. + +The guide should provide: + +- The exact setup sequence above, with copyable REST examples based on the API contract. +- The required scopes for each key, how to send organization keys as Bearer credentials, and how to discover the organization ID. +- Example request/response shapes for ESP creation, explicit-recipient test sends, activation, delivery-policy updates, and team provisioning. +- A clear explanation that `BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` selects the initial owner; it does not grant a special global-admin role. +- `.env` configuration, reruns, rotation, revocation, and migration from the old log-generated bootstrap key. +- A link to the machine-readable OpenAPI spec and guidance for agent or SDK integrations. +- A warning that `delivery:manage` can authorize changes beyond selecting a default ESP. + +Update `.env.example` and Compose documentation to show environment-variable names and placeholders, not real secret values. Keep the separate Compose/Mailpit follow-up responsible for the local transport setup (`mailpit:1025`), health checks, and configurable test recipient; these are not special behaviors in SendLit’s general API. + +### 5. Tests and implementation boundaries + +Tests should cover organization-key discovery isolation, scope failures and successes, organization binding, existing owner/admin sessions, policy validation and audit attribution, explicit-recipient ESP tests, bootstrap for both new and existing users, hashed-only/idempotent key registration, and team delivery inheritance. Update scope validation, API contract, OpenAPI documentation, and audit behavior alongside the routes. + +The repository currently has the earlier `SUPER_ADMIN_EMAIL` → `BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` rename and billing-alert recipient separation. The API capabilities, configured-key bootstrap, and integration guide described here are additional work. diff --git a/apps/api/docs/organizations.md b/apps/api/docs/organizations.md index ea38c29..5f82375 100644 --- a/apps/api/docs/organizations.md +++ b/apps/api/docs/organizations.md @@ -489,7 +489,8 @@ must not be interpreted more broadly than these actions. | Create/update/test/rotate organization ESP | Yes | Yes | No | `esps:manage` | | Retire/delete organization ESP | Yes | No | No | No | | Read organization ESP metadata/health | Yes | Yes | No | `esps:read` | -| Change organization delivery/quota policy | Yes | Yes | No | No | +| Read organization delivery policy | Yes | Yes | No | `delivery:read` | +| Change organization delivery/quota policy | Yes | Yes | No | `delivery:manage` | | Create/update/archive teams | Yes | Yes | No | `teams:provision` / `teams:manage` | | Physically purge organization/team | No | No | No | No; operator retention workflow only | | Create/suspend/resume/revoke an ESP grant | Yes | Yes | No | `grants:manage` | @@ -722,12 +723,16 @@ organization_api_keys created_at timestamptz ``` -Plaintext keys use a distinct prefix such as `sl_org_live_...`, are returned -once, and are stored only as a cryptographic hash. +Organization keys use the `sl_org_live_...` prefix and are stored only as a +cryptographic hash. API-created keys are returned once; self-host configured +keys are supplied by the operator through `.env` and are never returned or +logged by SendLit. Supported Phase 1 scopes: - `organization:read` +- `delivery:read` +- `delivery:manage` - `teams:provision` - `teams:read` - `teams:manage` @@ -737,9 +742,12 @@ Supported Phase 1 scopes: - `grants:manage` - `usage:read` -The common CourseLit runtime key should omit `esps:manage`; provider -credentials are configured through a more privileged dashboard session or -separate key. +The CourseLit runtime key should use only `organization:read`, +`teams:provision`, and `teams:read`. A one-time delivery setup key may use +`organization:read`, `esps:read`, `esps:manage`, `delivery:read`, and +`delivery:manage`. The last scope authorizes every field in the delivery +policy schema, including quota and team-delivery controls, so revoke that key +after setup when no further policy automation is required. ### Team API keys @@ -1526,6 +1534,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`. @@ -1552,6 +1565,19 @@ before adding them. Email invitation and acceptance tokens are deferred. ### Organization policy and grants +```text +GET /provisioning/organization +``` + +`GET /provisioning/organization` requires an organization key with +`organization:read` and returns only the organization bound to that key. +Delivery-policy reads and updates continue to accept owner/admin user +sessions; organization keys must be bound to the path organization and have +`delivery:read` or `delivery:manage`, respectively. The `delivery:manage` +scope covers all fields in the update schema, including quota and team +delivery controls. Policy changes made by a key are audited with that key as +the actor. + ```text GET /organizations/:organizationId/delivery-policy PUT /organizations/:organizationId/delivery-policy @@ -2157,8 +2183,9 @@ content, or unredacted webhook credentials. - Better Auth user deletion cannot bypass last-owner or membership-retention rules; membership FKs restrict user deletion. - Better Auth API keys and API-key-created mock sessions are not enabled. -- Organization and team key secrets are hashed, scoped, expirable, revocable, - and shown once. +- Organization and team key secrets are hashed, scoped, expirable, and + revocable. API-created secrets are shown once; configured bootstrap keys are + supplied by the operator and never logged or returned. - Provider secrets are encrypted and never returned. - Sender/header values reject injection. - Provider correlation metadata uses opaque SendLit IDs, not external tenant ID @@ -2238,7 +2265,9 @@ content, or unredacted webhook credentials. - Team pages show a sanitized "Provided by " sending option. - Team ESP pages show only team-owned configurations. - Team creation offers/apply organization defaults. -- Organization keys are shown once at creation. +- API-created organization keys are shown once at creation; configured + self-host keys are supplied and retained by the operator (`.env` for the + delivery setup and team provisioning keys in `.env`. - Managed/shared quota appears only for organization delivery. - Team-source sends clearly show that they bypass organization quota. - Organization members and team members are administered separately. @@ -2339,13 +2368,18 @@ Do not automatically create a team until the user chooses team name/delivery setup, unless product UX explicitly decides to combine organization and first team onboarding. Either path uses the same transactional creation service. -Bootstrap/super-admin initialization creates: +Initial organization owner bootstrap creates: - the user; - its organization; -- owner membership; and -- optionally an organization key/team only when the plaintext can be shown - safely. +- owner membership; +- the default team if one is missing; and +- any configured organization keys after storing only their hashes. Bootstrap + never logs key secrets and also registers keys when the configured owner + already exists. + +For the copyable headless REST workflow and key configuration, see the +[headless provisioning guide](../../docs/integrations/headless-provisioning.md). ### Removed configuration diff --git a/apps/api/docs/platform-customer.md b/apps/api/docs/platform-customer.md index adf6975..04c2ad0 100644 --- a/apps/api/docs/platform-customer.md +++ b/apps/api/docs/platform-customer.md @@ -6,6 +6,11 @@ team delivery capabilities, hybrid managed/custom routing, managed-delivery quota, provisioning, feedback correlation, REST/OpenAPI, MCP, and operator controls._ +> Superseded by [`organizations.md`](./organizations.md). This historical +> proposal describes a platform-customer model that was not implemented; use +> the [headless provisioning guide](/developers/headless-provisioning) for the +> current REST setup and organization-key scopes. + ## Executive summary SendLit serves two related but distinct customer models: 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/docs/transactional-emails.md b/apps/api/docs/transactional-emails.md index 19c33ba..b49844c 100644 --- a/apps/api/docs/transactional-emails.md +++ b/apps/api/docs/transactional-emails.md @@ -18,8 +18,9 @@ invariants: - Recipients must be subscribed `contacts` rows; transactional mail **must** reach unsubscribed addresses and addresses that aren't contacts at all. -- A team must configure a non-empty physical mailing address before any email, - including transactional mail, can be accepted or delivered. +- Campaign mail requires a non-empty physical mailing address for its managed + compliance footer. Transactional mail does not render that footer and does + not require a mailing address. - The campaign renderer injects an unsubscribe link and the CAN-SPAM mailing address — required for marketing mail, actively wrong for a password reset. - Delivery is driven by a 60s due-poll over `ongoing_sequences`; transactional diff --git a/apps/api/docs/transactional-vs-marketing-templates.md b/apps/api/docs/transactional-vs-marketing-templates.md index 92ac50d..4f6becc 100644 --- a/apps/api/docs/transactional-vs-marketing-templates.md +++ b/apps/api/docs/transactional-vs-marketing-templates.md @@ -40,10 +40,9 @@ which it is valid. Template purpose is immutable after creation. Changing purpose can silently change compliance and API behavior, so conversion is performed by duplication. -SendLit continues to require a configured workspace mailing address before any -send as a conservative platform policy. Transactional messages do not render -that address or an unsubscribe footer. This policy is separate from template -rendering. +Campaigns require a configured workspace mailing address for their managed +compliance footer. Transactional messages do not render that address or an +unsubscribe footer and do not require a mailing address. ## Invariants 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/api-validation.test.ts b/apps/api/src/api-validation.test.ts index a353a5c..e0ad63f 100644 --- a/apps/api/src/api-validation.test.ts +++ b/apps/api/src/api-validation.test.ts @@ -31,7 +31,9 @@ import { provisionTeamBodySchema, renameTeamBodySchema, } from "../../../packages/api-contract/src/schemas/teams"; +import { organizationKeyScopes } from "../../../packages/api-contract/src/schemas/organizations"; import { upsertFeedbackConnectionBodySchema } from "../../../packages/api-contract/src/schemas/feedback"; +import { createOrganizationApiKeyBodySchema } from "../../../packages/api-contract/src/schemas/organizations"; import { listDeliveryEventsQuerySchema } from "../../../packages/api-contract/src/schemas/delivery-events"; import { listSuppressionsQuerySchema, @@ -41,6 +43,25 @@ import { openApiDocument } from "./openapi"; import { transactionalEmailSchema } from "../../../packages/api-contract/src/schemas/transactional"; describe("API input validation schemas", () => { + it("accepts the explicit organization delivery scopes", () => { + expect( + createOrganizationApiKeyBodySchema.safeParse({ + name: "Delivery setup", + scopes: [ + "organization:read", + "delivery:read", + "delivery:manage", + ], + }).success, + ).toBe(true); + expect( + createOrganizationApiKeyBodySchema.safeParse({ + name: "Invalid scope", + scopes: ["delivery:write"], + }).success, + ).toBe(false); + }); + it("validates create and partial-update bodies for user ESPs", () => { expect( createEspConfigBodySchema.safeParse({ @@ -525,18 +546,129 @@ describe("bounce/complaint feedback and suppression schemas", () => { }); describe("OpenAPI authentication metadata", () => { - it("declares API key auth globally so Swagger UI sends the header", () => { + it("declares distinct team, user, and organization credentials", () => { expect(openApiDocument.components?.securitySchemes).toMatchObject({ - apiKeyAuth: { + teamApiKey: { type: "apiKey", in: "header", name: "x-sendlit-apikey", }, + userAccessToken: { + type: "http", + scheme: "bearer", + }, + organizationApiKey: { + type: "http", + scheme: "bearer", + }, + }); + expect(openApiDocument.security).toEqual([ + { teamApiKey: [] }, + { userAccessToken: [] }, + ]); + expect(openApiDocument.components?.securitySchemes).toMatchObject({ + dashboardSession: { type: "apiKey", in: "cookie" }, + dashboardSecureSession: { type: "apiKey", in: "cookie" }, }); - expect(openApiDocument.security).toContainEqual({ apiKeyAuth: [] }); + expect(organizationKeyScopes).toEqual( + expect.arrayContaining(["delivery:read", "delivery:manage"]), + ); + }); + + it("documents the accepted credentials on each operation", () => { + expect(openApiDocument.paths["/contacts"]?.post?.security).toEqual([ + { teamApiKey: [] }, + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ]); + expect(openApiDocument.paths["/teams"]?.get?.security).toEqual([ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ]); + expect( + openApiDocument.paths["/provisioning/teams"]?.post?.security, + ).toEqual([{ organizationApiKey: [] }]); + expect( + openApiDocument.paths["/organizations/{organizationId}/teams"]?.get + ?.security, + ).toEqual([ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + { organizationApiKey: [] }, + ]); + expect(openApiDocument.paths["/organizations"]?.post?.security).toEqual( + [ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ], + ); + expect( + openApiDocument.paths["/organizations/{organizationId}"]?.get + ?.security, + ).toEqual([ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + { organizationApiKey: [] }, + ]); + expect( + openApiDocument.paths["/provisioning/organization"]?.get?.security, + ).toEqual([{ organizationApiKey: [] }]); + expect( + openApiDocument.paths[ + "/organizations/{organizationId}/delivery-policy" + ]?.get?.security, + ).toEqual([ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + { organizationApiKey: [] }, + ]); + expect( + openApiDocument.paths[ + "/organizations/{organizationId}/delivery-policy" + ]?.put?.security, + ).toEqual([ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + { organizationApiKey: [] }, + ]); + expect( + openApiDocument.paths[ + "/organizations/{organizationId}/delivery-policy" + ]?.put?.description, + ).toContain("including shared quotas and team delivery controls"); + expect( + openApiDocument.paths["/provisioning/organization"]?.get + ?.description, + ).toContain("organization:read scope"); + expect( + openApiDocument.paths[ + "/organizations/{organizationId}/esps/{espId}/test" + ]?.post?.description, + ).toContain("explicit `to` address"); + expect( + openApiDocument.paths["/organizations/{organizationId}/keys"]?.get + ?.security, + ).toEqual([ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ]); + expect( + openApiDocument.paths["/billing/catalog"]?.get?.security, + ).toEqual([]); expect( openApiDocument.paths["/provisioning/teams"]?.post?.description, ).toContain("scoped organization key as a Bearer token"); + expect(openApiDocument.paths["/contacts"]?.post?.description).toContain( + "team API key or a user OAuth access token", + ); }); it("generates paths for the feedback, delivery-events, and suppressions routes", () => { diff --git a/apps/api/src/apikey/queries.test.ts b/apps/api/src/apikey/queries.test.ts index 482848e..6837430 100644 --- a/apps/api/src/apikey/queries.test.ts +++ b/apps/api/src/apikey/queries.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; vi.mock("../db/client", async () => { const { makeTestDb } = await import("../test/db.js"); @@ -9,11 +10,16 @@ import { db } from "../db/client"; import { seedTeamAndContact, truncateAll, type TestDb } from "../test/db"; import { createApiKey, + createOrganizationApiKey, deleteApiKey, getApiKeyBySecret, getApiKeysByTeamId, + getOrganizationApiKeyBySecret, + registerConfiguredOrganizationApiKey, + revokeOrganizationApiKey, } from "./queries"; -import { API_KEY_PREFIX } from "./secret"; +import { API_KEY_PREFIX, generateOrganizationApiKeySecret } from "./secret"; +import { organizationApiKeys } from "../db/schema"; const tdb = db as unknown as TestDb; @@ -55,4 +61,138 @@ describe("api key queries (integration)", () => { expect(await getApiKeysByTeamId(one.team.id)).toHaveLength(1); expect(await getApiKeysByTeamId(two.team.id)).toHaveLength(1); }); + + it("registers configured organization keys idempotently and stores only hashes", async () => { + const { organization } = await seedTeamAndContact(tdb); + const secret = generateOrganizationApiKeySecret(); + const scopes = ["organization:read", "teams:provision", "teams:read"]; + + const first = await registerConfiguredOrganizationApiKey( + organization.id, + "CourseLit provisioning", + scopes, + secret, + ); + const second = await registerConfiguredOrganizationApiKey( + organization.id, + "CourseLit provisioning", + [...scopes].reverse(), + secret, + ); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.apiKey.id).toBe(first.apiKey.id); + expect(first.apiKey.keyHash).not.toContain(secret); + expect(first.apiKey.keyPrefix).toBe(secret.slice(0, 12)); + expect(await getOrganizationApiKeyBySecret(secret)).toMatchObject({ + id: first.apiKey.id, + organizationId: organization.id, + scopes, + }); + expect( + await tdb + .select() + .from(organizationApiKeys) + .where(eq(organizationApiKeys.organizationId, organization.id)), + ).toHaveLength(1); + }); + + it("fails closed for configured key scope, organization, format, and revocation mismatches", async () => { + const first = await seedTeamAndContact(tdb); + const second = await seedTeamAndContact(tdb); + const secret = generateOrganizationApiKeySecret(); + const scopes = ["organization:read", "delivery:manage"]; + const created = await registerConfiguredOrganizationApiKey( + first.organization.id, + "Delivery setup", + scopes, + secret, + ); + + await expect( + registerConfiguredOrganizationApiKey( + first.organization.id, + "Delivery setup", + ["organization:read"], + secret, + ), + ).rejects.toThrow("configured_organization_api_key_scope_mismatch"); + await expect( + registerConfiguredOrganizationApiKey( + second.organization.id, + "Delivery setup", + scopes, + secret, + ), + ).rejects.toThrow("configured_organization_api_key_org_mismatch"); + await expect( + registerConfiguredOrganizationApiKey( + first.organization.id, + "Delivery setup", + scopes, + "not-a-valid-key", + ), + ).rejects.toThrow("configured_organization_api_key_invalid"); + + expect( + await revokeOrganizationApiKey( + first.organization.id, + created.apiKey.organizationApiKeyId, + ), + ).toBe(true); + await expect( + registerConfiguredOrganizationApiKey( + first.organization.id, + "Delivery setup", + scopes, + secret, + ), + ).rejects.toThrow("configured_organization_api_key_revoked"); + expect(await getOrganizationApiKeyBySecret(secret)).toBeNull(); + }); + + it("does not allow a generated key with a different role to impersonate a configured key", async () => { + const { organization } = await seedTeamAndContact(tdb); + const { secret } = await createOrganizationApiKey( + organization.id, + "Existing key", + ["organization:read", "teams:read"], + ); + + await expect( + registerConfiguredOrganizationApiKey( + organization.id, + "Existing key", + ["organization:read", "teams:provision", "teams:read"], + secret, + ), + ).rejects.toThrow("configured_organization_api_key_scope_mismatch"); + }); + + it("fails closed when a configured key is expired", async () => { + const { organization } = await seedTeamAndContact(tdb); + const secret = generateOrganizationApiKeySecret(); + const scopes = ["organization:read", "teams:provision", "teams:read"]; + const created = await registerConfiguredOrganizationApiKey( + organization.id, + "Expired provisioning key", + scopes, + secret, + ); + await tdb + .update(organizationApiKeys) + .set({ expiresAt: new Date(Date.now() - 60_000) }) + .where(eq(organizationApiKeys.id, created.apiKey.id)); + + await expect( + registerConfiguredOrganizationApiKey( + organization.id, + "Expired provisioning key", + scopes, + secret, + ), + ).rejects.toThrow("configured_organization_api_key_expired"); + expect(await getOrganizationApiKeyBySecret(secret)).toBeNull(); + }); }); diff --git a/apps/api/src/apikey/queries.ts b/apps/api/src/apikey/queries.ts index c82885b..8fe6cb8 100644 --- a/apps/api/src/apikey/queries.ts +++ b/apps/api/src/apikey/queries.ts @@ -6,6 +6,7 @@ import { generateApiKeySecret, generateOrganizationApiKeySecret, hashApiKeySecret, + isOrganizationApiKeySecret, } from "./secret"; export type ApiKey = typeof teamApiKeys.$inferSelect; @@ -106,6 +107,77 @@ export async function createOrganizationApiKey( return { apiKey, secret }; } +/** Register an operator-supplied organization key without ever returning or + * logging its plaintext. Re-running bootstrap with the same secret and role + * is a no-op; a revoked, expired, cross-organization, or differently scoped + * key fails closed instead of being silently repaired. */ +export async function registerConfiguredOrganizationApiKey( + organizationId: string, + name: string, + scopes: string[], + secret: string, + createdByUserId?: string, +): Promise<{ apiKey: OrganizationApiKey; created: boolean }> { + if (!isOrganizationApiKeySecret(secret)) { + throw new Error("configured_organization_api_key_invalid"); + } + + const keyHash = hashApiKeySecret(secret); + const expectedScopes = [...new Set(scopes)].sort(); + const validateExisting = ( + apiKey: OrganizationApiKey, + ): { apiKey: OrganizationApiKey; created: false } => { + if (apiKey.organizationId !== organizationId) { + throw new Error("configured_organization_api_key_org_mismatch"); + } + if (apiKey.revokedAt) { + throw new Error("configured_organization_api_key_revoked"); + } + if (apiKey.expiresAt && apiKey.expiresAt <= new Date()) { + throw new Error("configured_organization_api_key_expired"); + } + const actualScopes = [...new Set(apiKey.scopes)].sort(); + if (JSON.stringify(actualScopes) !== JSON.stringify(expectedScopes)) { + throw new Error("configured_organization_api_key_scope_mismatch"); + } + return { apiKey, created: false }; + }; + + const [existing] = await db + .select() + .from(organizationApiKeys) + .where(eq(organizationApiKeys.keyHash, keyHash)) + .limit(1); + if (existing) return validateExisting(existing); + + const [created] = await db + .insert(organizationApiKeys) + .values({ + organizationId, + name, + scopes: expectedScopes, + keyHash, + keyPrefix: displayPrefix(secret), + createdByUserId, + }) + .onConflictDoNothing({ target: organizationApiKeys.keyHash }) + .returning(); + if (created) return { apiKey: created, created: true }; + + // Another bootstrap process may have registered this hash concurrently. + const [raced] = await db + .select() + .from(organizationApiKeys) + .where(eq(organizationApiKeys.keyHash, keyHash)) + .limit(1); + if (!raced) { + throw new Error( + "configured_organization_api_key_registration_conflict", + ); + } + return validateExisting(raced); +} + export async function getOrganizationApiKeys( organizationId: string, ): Promise { diff --git a/apps/api/src/apikey/secret.ts b/apps/api/src/apikey/secret.ts index 1853cff..11a2417 100644 --- a/apps/api/src/apikey/secret.ts +++ b/apps/api/src/apikey/secret.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from "crypto"; * makes the credential type obvious at a glance without exposing the secret. */ export const API_KEY_PREFIX = "sl_live_"; export const ORGANIZATION_API_KEY_PREFIX = "sl_org_live_"; +const ORGANIZATION_API_KEY_SECRET_PATTERN = /^sl_org_live_[A-Za-z0-9_-]{43}$/; /** Characters of the secret shown in list/UI surfaces (`sl_live_a1b2`). */ const DISPLAY_PREFIX_LENGTH = 12; @@ -20,6 +21,11 @@ export function generateOrganizationApiKeySecret(): string { )}`; } +/** Accept only the 256-bit, base64url organization key format generated here. */ +export function isOrganizationApiKeySecret(secret: string): boolean { + return ORGANIZATION_API_KEY_SECRET_PATTERN.test(secret); +} + /** Keys are stored hashed, like passwords — but as a plain SHA-256, not * bcrypt/argon2: the secret is already 256 bits of CSPRNG output, so * brute-forcing the hash is infeasible and a slow KDF would only tax every diff --git a/apps/api/src/auth/better-auth.ts b/apps/api/src/auth/better-auth.ts index eea8927..e4e6d06 100644 --- a/apps/api/src/auth/better-auth.ts +++ b/apps/api/src/auth/better-auth.ts @@ -140,14 +140,17 @@ export const auth = betterAuth({ schema, }), trustedOrigins: [webClientUrl, authBaseUrl], - advanced: authCookieDomain - ? { - crossSubDomainCookies: { - enabled: true, - domain: authCookieDomain, - }, - } - : undefined, + advanced: { + cookiePrefix: "sendlit", + ...(authCookieDomain + ? { + crossSubDomainCookies: { + enabled: true, + domain: authCookieDomain, + }, + } + : {}), + }, user: { additionalFields: { defaultOrganizationId: { diff --git a/apps/api/src/auth/resolve-auth.test.ts b/apps/api/src/auth/resolve-auth.test.ts index 527c71b..3e1213d 100644 --- a/apps/api/src/auth/resolve-auth.test.ts +++ b/apps/api/src/auth/resolve-auth.test.ts @@ -221,7 +221,7 @@ describe("resolveAuth", () => { resolveAuth( { apiKeyHeader: "api-key", - headers: { cookie: "better-auth.session_token=s" }, + headers: { cookie: "sendlit.session_token=s" }, }, authDeps, ), @@ -241,7 +241,7 @@ describe("resolveAuth", () => { ).resolves.toEqual({ status: "unauthorized" }); await expect( resolveAuth( - { headers: { cookie: "better-auth.session_token=s" } }, + { headers: { cookie: "sendlit.session_token=s" } }, deps({ resolveSession: vi.fn( async () => @@ -265,7 +265,7 @@ describe("resolveAuth", () => { await expect( resolveAuth( - { headers: { cookie: "better-auth.session_token=s" } }, + { headers: { cookie: "sendlit.session_token=s" } }, authDeps, ), ).resolves.toMatchObject({ diff --git a/apps/api/src/automation/process-ongoing-sequence.ts b/apps/api/src/automation/process-ongoing-sequence.ts index 820aca5..b8cbcbb 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,14 +314,20 @@ 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({ + purpose: "campaign", from, to, subject, @@ -337,10 +351,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 +379,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..505b75c --- /dev/null +++ b/apps/api/src/billing/alerts.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const emailMocks = vi.hoisted(() => ({ + createTransport: vi.fn(), + sendMail: vi.fn(), +})); + +vi.mock("nodemailer", () => ({ + createTransport: emailMocks.createTransport, +})); +vi.mock("../services/log", () => ({ + default: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})); +vi.mock("../observability/posthog", () => ({ + captureError: vi.fn(), + captureEvent: vi.fn(), +})); +vi.mock("./metrics", () => ({ recordBillingMetric: vi.fn() })); + +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 { pageBillingAlert } from "./alerts"; +import { + collectBillingSloAlerts, + recordBillingHourlySuccess, + recordWebhookSignatureFailure, + resetBillingAlertsForTests, +} from "./alerts"; + +const tdb = db as unknown as TestDb; + +beforeEach(async () => { + resetBillingAlertsForTests(); + recordBillingHourlySuccess(); + emailMocks.createTransport.mockReturnValue({ + sendMail: emailMocks.sendMail, + }); + await truncateAll(tdb); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +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); + }); + + it("sends billing emails only to BILLING_ALERT_EMAIL recipients", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("EMAIL_HOST", "mail.example.com"); + vi.stubEnv("EMAIL_FROM", "alerts@example.com"); + vi.stubEnv( + "BILLING_ALERT_EMAIL", + " admin@example.com, ops@example.com ", + ); + + await pageBillingAlert({ + code: "webhook_quarantined", + message: "Review quarantined event", + details: { count: 1 }, + }); + + expect(emailMocks.createTransport).toHaveBeenCalledOnce(); + expect(emailMocks.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "admin@example.com, ops@example.com", + }), + ); + }); + + it("does not fall back to SUPER_ADMIN_EMAIL when alert recipients are unset", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("EMAIL_HOST", "mail.example.com"); + vi.stubEnv("EMAIL_FROM", "alerts@example.com"); + vi.stubEnv("BILLING_ALERT_EMAIL", ""); + vi.stubEnv("SUPER_ADMIN_EMAIL", "legacy@example.com"); + + await pageBillingAlert({ + code: "webhook_quarantined", + message: "Review quarantined event", + details: { count: 1 }, + }); + + expect(emailMocks.createTransport).not.toHaveBeenCalled(); + expect(emailMocks.sendMail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/billing/alerts.ts b/apps/api/src/billing/alerts.ts new file mode 100644 index 0000000..56ea449 --- /dev/null +++ b/apps/api/src/billing/alerts.ts @@ -0,0 +1,287 @@ +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 || ""; + 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/bootstrap-cli.ts b/apps/api/src/bootstrap-cli.ts index 3932bba..c8dd73d 100644 --- a/apps/api/src/bootstrap-cli.ts +++ b/apps/api/src/bootstrap-cli.ts @@ -1,19 +1,20 @@ import { config as loadDotFile } from "dotenv"; loadDotFile(); -import { createSuperAdminIfMissing } from "./bootstrap.js"; +import { createInitialOrganizationOwnerIfMissing } from "./bootstrap.js"; import { pool } from "./db/client.js"; import logger from "./services/log.js"; /** * One-shot container entrypoint. It runs after migrations and creates the - * configured self-host administrator, default team, and one-time API key. + * configured initial organization owner, default team, and any configured + * organization API keys supplied through environment configuration. */ -createSuperAdminIfMissing() +createInitialOrganizationOwnerIfMissing() .catch((error) => { logger.error( { error: error instanceof Error ? error.message : String(error) }, - "Failed to bootstrap the super admin account", + "Failed to bootstrap the initial organization owner", ); process.exitCode = 1; }) diff --git a/apps/api/src/bootstrap.test.ts b/apps/api/src/bootstrap.test.ts new file mode 100644 index 0000000..77ca539 --- /dev/null +++ b/apps/api/src/bootstrap.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const bootstrapMocks = vi.hoisted(() => ({ + findUserByEmail: vi.fn(), + registerConfiguredOrganizationApiKey: vi.fn(), + ensureDefaultOrganization: vi.fn(), + getOrganizationMembership: vi.fn(), + insert: vi.fn(), + info: vi.fn(), + error: vi.fn(), +})); + +vi.mock("./apikey/queries", () => ({ + registerConfiguredOrganizationApiKey: + bootstrapMocks.registerConfiguredOrganizationApiKey, +})); +vi.mock("./db/client", () => ({ db: { insert: bootstrapMocks.insert } })); +vi.mock("./db/schema", () => ({ user: {} })); +vi.mock("./organization/queries", () => ({ + ensureDefaultOrganization: bootstrapMocks.ensureDefaultOrganization, + getOrganizationMembership: bootstrapMocks.getOrganizationMembership, +})); +vi.mock("./services/log", () => ({ + default: { info: bootstrapMocks.info, error: bootstrapMocks.error }, +})); +vi.mock("./user/queries", () => ({ + findUserByEmail: bootstrapMocks.findUserByEmail, +})); + +import { createInitialOrganizationOwnerIfMissing } from "./bootstrap"; + +beforeEach(() => { + vi.resetAllMocks(); + bootstrapMocks.ensureDefaultOrganization.mockResolvedValue({ + id: "org-internal", + organizationId: "org_public", + }); + bootstrapMocks.getOrganizationMembership.mockResolvedValue({ + role: "owner", + }); + bootstrapMocks.registerConfiguredOrganizationApiKey.mockResolvedValue({ + apiKey: { organizationApiKeyId: "key-id" }, + created: true, + }); + bootstrapMocks.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + id: "new-user", + email: "owner@example.com", + name: "owner", + }, + ]), + }), + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("initial organization owner bootstrap", () => { + it("does not use the old SUPER_ADMIN_EMAIL setting", async () => { + vi.stubEnv("SUPER_ADMIN_EMAIL", "legacy@example.com"); + vi.stubEnv("BOOTSTRAP_ORGANIZATION_OWNER_EMAIL", ""); + + await createInitialOrganizationOwnerIfMissing(); + + expect(bootstrapMocks.findUserByEmail).not.toHaveBeenCalled(); + expect(bootstrapMocks.ensureDefaultOrganization).not.toHaveBeenCalled(); + }); + + it("ensures the organization for an existing owner on every bootstrap run", async () => { + vi.stubEnv("BOOTSTRAP_ORGANIZATION_OWNER_EMAIL", "Owner@Example.com"); + bootstrapMocks.findUserByEmail.mockResolvedValue({ id: "existing" }); + + await createInitialOrganizationOwnerIfMissing(); + + expect(bootstrapMocks.findUserByEmail).toHaveBeenCalledWith( + "owner@example.com", + ); + expect(bootstrapMocks.ensureDefaultOrganization).toHaveBeenCalledWith( + "existing", + ); + expect( + bootstrapMocks.registerConfiguredOrganizationApiKey, + ).not.toHaveBeenCalled(); + }); + + it("registers both environment keys without logging their secrets", async () => { + const setupSecret = `sl_org_live_${"a".repeat(43)}`; + const runtimeSecret = `sl_org_live_${"b".repeat(43)}`; + vi.stubEnv("BOOTSTRAP_ORGANIZATION_OWNER_EMAIL", "owner@example.com"); + vi.stubEnv("BOOTSTRAP_DELIVERY_SETUP_API_KEY", setupSecret); + vi.stubEnv("BOOTSTRAP_TEAM_PROVISIONING_API_KEY", runtimeSecret); + bootstrapMocks.findUserByEmail.mockResolvedValue({ id: "existing" }); + + await createInitialOrganizationOwnerIfMissing(); + + expect( + bootstrapMocks.registerConfiguredOrganizationApiKey, + ).toHaveBeenNthCalledWith( + 1, + "org-internal", + "[Auto-generated] Delivery Setup", + [ + "organization:read", + "esps:read", + "esps:manage", + "delivery:read", + "delivery:manage", + ], + setupSecret, + "existing", + ); + expect( + bootstrapMocks.registerConfiguredOrganizationApiKey, + ).toHaveBeenNthCalledWith( + 2, + "org-internal", + "[Auto-generated] Team Provisioning", + ["organization:read", "teams:provision", "teams:read"], + runtimeSecret, + "existing", + ); + const logged = JSON.stringify(bootstrapMocks.info.mock.calls); + expect(logged).not.toContain(setupSecret); + expect(logged).not.toContain(runtimeSecret); + }); + + it("fails closed instead of registering keys for a non-owner membership", async () => { + vi.stubEnv("BOOTSTRAP_ORGANIZATION_OWNER_EMAIL", "owner@example.com"); + vi.stubEnv( + "BOOTSTRAP_TEAM_PROVISIONING_API_KEY", + `sl_org_live_${"b".repeat(43)}`, + ); + bootstrapMocks.findUserByEmail.mockResolvedValue({ id: "existing" }); + bootstrapMocks.getOrganizationMembership.mockResolvedValue({ + role: "member", + }); + + await expect(createInitialOrganizationOwnerIfMissing()).rejects.toThrow( + "initial_organization_owner_required", + ); + expect( + bootstrapMocks.registerConfiguredOrganizationApiKey, + ).not.toHaveBeenCalled(); + }); + + it("creates a missing initial owner before ensuring the organization", async () => { + vi.stubEnv("BOOTSTRAP_ORGANIZATION_OWNER_EMAIL", "Owner@Example.com"); + bootstrapMocks.findUserByEmail.mockResolvedValue(null); + + await createInitialOrganizationOwnerIfMissing(); + + expect(bootstrapMocks.insert).toHaveBeenCalledTimes(1); + expect(bootstrapMocks.ensureDefaultOrganization).toHaveBeenCalledWith( + "new-user", + ); + }); + + it("requires an initial owner email when configured keys are supplied", async () => { + vi.stubEnv("BOOTSTRAP_ORGANIZATION_OWNER_EMAIL", ""); + vi.stubEnv( + "BOOTSTRAP_DELIVERY_SETUP_API_KEY", + `sl_org_live_${"a".repeat(43)}`, + ); + vi.stubEnv( + "BOOTSTRAP_TEAM_PROVISIONING_API_KEY", + `sl_org_live_${"b".repeat(43)}`, + ); + + await expect(createInitialOrganizationOwnerIfMissing()).rejects.toThrow( + "initial_organization_owner_email_required", + ); + }); +}); diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index c2618be..5061b2d 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -1,78 +1,115 @@ import { randomUUID } from "crypto"; -import { createOrganizationApiKey } from "./apikey/queries"; +import { registerConfiguredOrganizationApiKey } from "./apikey/queries"; import { db } from "./db/client"; import { user } from "./db/schema"; -import { ensureDefaultOrganization } from "./organization/queries"; +import { + ensureDefaultOrganization, + getOrganizationMembership, +} from "./organization/queries"; import logger from "./services/log"; import { findUserByEmail } from "./user/queries"; /** - * If `SUPER_ADMIN_EMAIL` is set and no user exists for it yet, create the - * Better Auth user and default organization, then log an organization key once - * so an - * operator bringing the stack up via `docker compose` can grab it from - * `docker compose logs` without any manual OAuth sign-in step. + * Ensure the configured initial organization owner and their default + * organization exist, then register any configured, pre-generated keys. + * Plaintext keys come from configured environment variables and are never + * logged. * * This only ever provisions *one* team, once, at container start — it's a * dev/self-host convenience, not how a multi-tenant consumer (e.g. CourseLit, * creating a team per one of its own tenants, at any point after boot) should * provision teams. See `provisioning/routes.ts` for that. */ -export async function createSuperAdminIfMissing(): Promise { - const email = process.env.SUPER_ADMIN_EMAIL; - if (!email) return; +export async function createInitialOrganizationOwnerIfMissing(): Promise { + const email = process.env.BOOTSTRAP_ORGANIZATION_OWNER_EMAIL; + const configuredKeys = [ + { + secret: process.env.BOOTSTRAP_DELIVERY_SETUP_API_KEY?.trim(), + name: "[Auto-generated] Delivery Setup", + scopes: [ + "organization:read", + "esps:read", + "esps:manage", + "delivery:read", + "delivery:manage", + ], + }, + { + secret: process.env.BOOTSTRAP_TEAM_PROVISIONING_API_KEY?.trim(), + name: "[Auto-generated] Team Provisioning", + scopes: ["organization:read", "teams:provision", "teams:read"], + }, + ].flatMap((key) => { + const secret = key.secret?.trim(); + return secret ? [{ ...key, secret }] : []; + }); + + if (!email) { + if (configuredKeys.length > 0) { + throw new Error("initial_organization_owner_email_required"); + } + return; + } try { const normalizedEmail = email.toLowerCase(); - const existing = await findUserByEmail(normalizedEmail); - if (existing) return; - - const now = new Date(); - const [identity] = await db - .insert(user) - .values({ - id: randomUUID(), - email: normalizedEmail, - name: normalizedEmail.split("@")[0], - emailVerified: false, - createdAt: now, - updatedAt: now, - }) - .returning(); + let identity = await findUserByEmail(normalizedEmail); + if (!identity) { + const now = new Date(); + const [createdIdentity] = await db + .insert(user) + .values({ + id: randomUUID(), + email: normalizedEmail, + name: normalizedEmail.split("@")[0], + emailVerified: false, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (!createdIdentity) { + throw new Error("initial_organization_owner_create_failed"); + } + identity = createdIdentity; + } const organization = await ensureDefaultOrganization(identity.id); if (!organization) throw new Error("organization_bootstrap_failed"); - const { secret } = await createOrganizationApiKey( + const membership = await getOrganizationMembership( organization.id, - "Bootstrap", - [ - "organization:read", - "teams:provision", - "teams:read", - "teams:manage", - "teams:keys", - "esps:read", - "esps:manage", - "grants:manage", - "usage:read", - ], identity.id, ); + if (membership?.role !== "owner") { + throw new Error("initial_organization_owner_required"); + } + + const registeredKeys = []; + for (const key of configuredKeys) { + const registration = await registerConfiguredOrganizationApiKey( + organization.id, + key.name, + key.scopes, + key.secret, + identity.id, + ); + registeredKeys.push({ + name: key.name, + keyId: registration.apiKey.organizationApiKeyId, + created: registration.created, + }); + } - // Keys are stored hashed, so this log line is the only place the - // secret ever appears — exactly the "grab it from `docker compose - // logs` once" flow described above. logger.info( { userId: identity.id, organizationId: organization.organizationId, - organizationApiKey: secret, + registeredKeys, }, - "Super admin user and organization created", + "Initial organization bootstrap completed", ); } catch (err: any) { logger.error( { error: err.message }, - "Failed to create super admin user", + "Failed to create initial organization owner", ); throw err; } 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..5271d21 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -31,7 +31,7 @@ import deliveryEventsRoutes from "./delivery-feedback/delivery-events-routes"; import suppressionsRoutes from "./delivery-feedback/suppressions-routes"; import { assertEspEncryptionKeyConfigured } from "./utils/secret-crypto"; import { assertSuppressionHashKeyConfigured } from "./delivery-feedback/suppression-hash"; -import { createSuperAdminIfMissing } from "./bootstrap"; +import { createInitialOrganizationOwnerIfMissing } from "./bootstrap"; import { openApiDocument } from "./openapi"; import { captureError, @@ -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(createSuperAdminIfMissing) + .then(async () => { + const billingConfig = readBillingConfig(); + if (billingConfig.deploymentMode === "cloud") { + await recordRequestedCatalogRevision(billingConfig); + } + }) + .then(createInitialOrganizationOwnerIfMissing) .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/send.test.ts b/apps/api/src/mail/send.test.ts index a2a7c6b..5314e2e 100644 --- a/apps/api/src/mail/send.test.ts +++ b/apps/api/src/mail/send.test.ts @@ -42,6 +42,7 @@ describe("sendMail", () => { await expect( sendMail({ + purpose: "campaign", from: "Sender ", to: "contact@example.com", subject: "Hello", @@ -60,6 +61,7 @@ describe("sendMail", () => { await expect( sendMail({ + purpose: "campaign", from: "Sender ", to: "contact@example.com", subject: "Hello", @@ -85,6 +87,7 @@ describe("sendMail", () => { await expect( sendMail({ + purpose: "campaign", from: "Sender ", to: "contact@example.com", subject: "Hello", @@ -99,6 +102,7 @@ describe("sendMail", () => { const { sendMail } = await import("./send.js"); await sendMail({ + purpose: "campaign", from: "Sender ", to: "contact@example.com", subject: "Hello", @@ -122,4 +126,47 @@ describe("sendMail", () => { }), ); }); + + it("sends transactional mail without requiring a mailing address", async () => { + assertMailingAddressConfiguredMock.mockRejectedValueOnce( + new Error("mailing_address_required"), + ); + getTeamTransportMock.mockResolvedValue({ sendMail: sendMailMock }); + const { sendMail } = await import("./send.js"); + + await expect( + sendMail({ + purpose: "transactional", + from: "Sender ", + to: "contact@example.com", + subject: "Your sign-in code", + html: "

123456

", + teamId: "team-1", + }), + ).resolves.toEqual({ messageId: null, providerResponse: null }); + + expect(assertMailingAddressConfiguredMock).not.toHaveBeenCalled(); + expect(sendMailMock).toHaveBeenCalledTimes(1); + }); + + it("does not require a mailing address to test an ESP", async () => { + assertMailingAddressConfiguredMock.mockRejectedValueOnce( + new Error("mailing_address_required"), + ); + getTeamTransportMock.mockResolvedValue({ sendMail: sendMailMock }); + const { sendTestMail } = await import("./send.js"); + + await expect( + sendTestMail({ + from: "Sender ", + to: "owner@example.com", + subject: "ESP test", + html: "

Test

", + teamId: "team-1", + }), + ).resolves.toBeUndefined(); + + expect(assertMailingAddressConfiguredMock).not.toHaveBeenCalled(); + expect(sendMailMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/api/src/mail/send.ts b/apps/api/src/mail/send.ts index 7a30ea0..f5c72a3 100644 --- a/apps/api/src/mail/send.ts +++ b/apps/api/src/mail/send.ts @@ -24,6 +24,8 @@ interface MailInput { messageId?: string; } +type MailPurpose = "campaign" | "transactional"; + /** What `sendMail` hands back instead of discarding Nodemailer's * `SentMessageInfo` — the outbound ledger persists this for later * correlation. Both fields are `null` outside production, since `sendMail` @@ -53,6 +55,7 @@ async function resolveTeamTransporter( } export async function sendMail({ + purpose, from, to, subject, @@ -62,12 +65,15 @@ export async function sendMail({ espConfigId, secretVersion, messageId, -}: MailInput): Promise { +}: MailInput & { purpose: MailPurpose }): Promise { let result: SendMailResult = { messageId: null, providerResponse: null }; try { - // This is the final guard for all delivery paths, including queued - // work accepted before a team clears its address. - await assertMailingAddressConfigured(teamId); + // Marketing messages require the physical address used by their + // managed compliance footer. Transactional messages have no marketing + // footer and must remain deliverable without a workspace address. + if (purpose === "campaign") { + await assertMailingAddressConfigured(teamId); + } const transporter = await resolveTeamTransporter( teamId, espConfigId, @@ -117,7 +123,6 @@ export async function sendTestMail({ teamId, espConfigId, }: MailInput): Promise { - await assertMailingAddressConfigured(teamId); const transporter = await resolveTeamTransporter(teamId, espConfigId); await transporter.sendMail({ from, to, subject, html }); } diff --git a/apps/api/src/mail/worker.ts b/apps/api/src/mail/worker.ts index a98f0af..248eee6 100644 --- a/apps/api/src/mail/worker.ts +++ b/apps/api/src/mail/worker.ts @@ -28,15 +28,30 @@ 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; try { - await sendMail({ from, to, subject, html: body, headers, teamId }); + await sendMail({ + purpose: "campaign", + from, + to, + subject, + html: body, + headers, + teamId, + }); } catch (err: any) { logger.error( { @@ -101,6 +116,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 +134,7 @@ async function processTransactionalJob(job: Job) { await markTransactionalEmailSuppressed(row.id); if (outbound) { await releaseQuotaForOutbound(outbound.id, "suppressed"); + await releaseSendReservation(outbound.id); } return; } @@ -143,6 +168,7 @@ async function processTransactionalJob(job: Job) { } const result = await sendMail({ + purpose: "transactional", from: row.fromEmail || "", to: row.toEmail, subject: row.subject, @@ -160,6 +186,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 +207,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 +239,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/auth-context.test.ts b/apps/api/src/mcp/auth-context.test.ts index 3037f86..125f8b2 100644 --- a/apps/api/src/mcp/auth-context.test.ts +++ b/apps/api/src/mcp/auth-context.test.ts @@ -42,4 +42,16 @@ describe("SendLit MCP auth context", () => { }); expect(JSON.stringify(auth)).not.toContain("sl_live_secret"); }); + + it("does not treat organization API keys as MCP credentials", () => { + expect( + createSendLitMcpAuthInfo({ + authKind: "organization_key", + organizationId: "org-internal", + organizationApiKeyId: "oak_public", + apiKeyId: "oak_public", + teamId: "team-1", + }), + ).toBeNull(); + }); }); 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/server.ts b/apps/api/src/mcp/server.ts index a9a2f38..b8e4bce 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -25,11 +25,11 @@ export function buildMcpServer(): McpServer { name: "SendLit", version: SENDLIT_MCP_VERSION, description: - "SendLit MCP server — compose, send and automate email for a SendLit team. Manage contacts and segments, templates and media, broadcasts and sequences, transactional email, sending providers, settings, API keys, and delivery feedback.", + "SendLit MCP server — compose, send and automate email for a SendLit team. Manage contacts and segments, templates and media, broadcasts and sequences, transactional email, sending providers, settings, team API keys, and delivery feedback. Organization bootstrap, shared-delivery policy, and team provisioning use the REST API, not MCP.", }, { instructions: - "Use read tools to inspect existing state before mutations. Sending, activation, deletion, key management, and suppression-release tools have external or destructive effects; confirm the requested target and inputs before calling them.", + "Use read tools to inspect existing state before mutations. Sending, activation, deletion, key management, and suppression-release tools have external or destructive effects; confirm the requested target and inputs before calling them. MCP is team-scoped and does not accept organization API keys. Use the REST API and /openapi.json for organization discovery, headless shared-delivery setup, and team provisioning.", cacheHints: { "server/discover": { ttlMs: 300_000, 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/esp.ts b/apps/api/src/mcp/tools/esp.ts index adc56cf..a743011 100644 --- a/apps/api/src/mcp/tools/esp.ts +++ b/apps/api/src/mcp/tools/esp.ts @@ -147,7 +147,7 @@ export function registerEspTools(server: McpToolRegistrar): void { "send_test_email", { description: - "Sends a test email through the team's default ESP to verify it works. Requires a mailing address. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", + "Sends a test email through the team's default ESP to verify it works. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", inputSchema: { to: z.string().email().optional(), }, @@ -363,7 +363,7 @@ export function registerEspTools(server: McpToolRegistrar): void { "test_esp", { description: - "Sends a test email through a user-managed ESP (by its ESP ID) to verify it works. Requires a mailing address. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", + "Sends a test email through a user-managed ESP (by its ESP ID) to verify it works. Defaults to the current user's own email address if `to` is omitted (OAuth sessions only — API-key sessions must supply `to`).", inputSchema: { espId: z.string().min(1), to: z.string().email().optional(), diff --git a/apps/api/src/mcp/tools/general-settings.ts b/apps/api/src/mcp/tools/general-settings.ts index f70340f..fad5e73 100644 --- a/apps/api/src/mcp/tools/general-settings.ts +++ b/apps/api/src/mcp/tools/general-settings.ts @@ -21,7 +21,7 @@ export function registerGeneralSettingsTools(server: McpToolRegistrar): void { "get_general_settings", { description: - "Returns the team's general workspace settings. A non-empty mailing address is required before the team can send any email and is rendered in email footers. Returns defaults (null fields) when nothing has been saved yet.", + "Returns the team's general workspace settings. The mailing address is used in the managed marketing footer and is required for broadcasts and sequences. Returns defaults (null fields) when nothing has been saved yet.", outputSchema: generalSettingsSchema, annotations: { readOnlyHint: true, @@ -41,7 +41,7 @@ export function registerGeneralSettingsTools(server: McpToolRegistrar): void { "update_general_settings", { description: - "Updates the team's general workspace settings. Omitted fields are left unchanged; send an empty string to clear a field. `mailingAddress` is the physical postal address rendered in email footers and is required before the team can send email (CAN-SPAM/GDPR requirement).", + "Updates the team's general workspace settings. Omitted fields are left unchanged; send an empty string to clear a field. `mailingAddress` is the physical postal address rendered in the managed marketing footer and required for broadcasts and sequences.", inputSchema: { mailingAddress: z .string() 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..f02dfcf 100644 --- a/apps/api/src/mcp/tools/transactional.ts +++ b/apps/api/src/mcp/tools/transactional.ts @@ -13,12 +13,18 @@ import { listTransactionalEmails, toPublicTransactionalEmail, } from "../../transactional/queries"; -import { MAILING_ADDRESS_REQUIRED } from "../../settings/general/constants"; 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 +121,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 @@ -147,10 +154,6 @@ export function registerTransactionalTools(server: McpToolRegistrar): void { return errorResult("Template rendering failed"); case "esp_not_configured": return errorResult("Team ESP is not configured."); - case MAILING_ADDRESS_REQUIRED: - return errorResult( - "A mailing address is required before sending email.", - ); case "esp_not_found": return errorResult("ESP not found"); case "recipient_suppressed": 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..11d3fa5 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1,13 +1,118 @@ import { generateOpenApi } from "@ts-rest/open-api"; import { contract } from "@sendlit/api-contract"; +type AuthMode = + | "teamOrUser" + | "user" + | "dashboardSession" + | "organization" + | "userOrOrganization" + | "public"; + +const securityByAuthMode: Record>> = { + teamOrUser: [ + { teamApiKey: [] }, + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ], + user: [ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ], + dashboardSession: [ + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + ], + organization: [{ organizationApiKey: [] }], + userOrOrganization: [ + { userAccessToken: [] }, + { dashboardSession: [] }, + { dashboardSecureSession: [] }, + { organizationApiKey: [] }, + ], + public: [], +}; + +const authenticationDescriptions: Record = { + teamOrUser: + "Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + user: "Requires a user OAuth access token or a dashboard session cookie.", + dashboardSession: + "Requires a dashboard session cookie; OAuth access tokens and API keys are not accepted. Requests must pass the dashboard's same-origin and CSRF checks. Sensitive billing actions require recent authentication and a single-use billing action token; POST /billing/action-token issues that token.", + organization: + "Requires a scoped organization API key sent as a Bearer token.", + userOrOrganization: + "Requires a user OAuth access token or dashboard session, or a scoped organization API key. The caller must also have the required organization role or key scope.", + public: "No authentication is required.", +}; + +const dashboardSessionOnlyOperations = new Set([ + "POST /billing/action-token", + "POST /billing/organization-checkouts", + "DELETE /organizations/:organizationId", + "POST /organizations/:organizationId/abandon", + "POST /organizations/:organizationId/billing/checkout", + "POST /organizations/:organizationId/billing/portal", + "POST /organizations/:organizationId/billing/plan-change", +]); + +function getAuthMode(path: string, method: string): AuthMode { + const verb = method.toUpperCase(); + + if (dashboardSessionOnlyOperations.has(`${verb} ${path}`)) { + return "dashboardSession"; + } + if (path === "/billing/catalog") return "public"; + if (path.startsWith("/provisioning/")) return "organization"; + if (path.startsWith("/teams")) return "user"; + if (path.startsWith("/billing/")) return "user"; + + if (path.startsWith("/organizations")) { + if ( + path === "/organizations" || + (path === "/organizations/:organizationId" && verb !== "GET") || + path.includes("/members") || + path.includes("/keys") || + path.endsWith("/abandon") || + path.includes("/sending-domains") || + path.endsWith("/enter") || + path.includes("/billing/") || + path.endsWith("/plan-usage") + ) { + return "user"; + } + + const organizationKeyRoutes = + (path === "/organizations/:organizationId" && verb === "GET") || + path === "/organizations/:organizationId/teams" || + path === "/organizations/:organizationId/teams/:teamId" || + path.startsWith("/organizations/:organizationId/esps") || + path === "/organizations/:organizationId/usage" || + path === "/organizations/:organizationId/mail-activity" || + path === "/organizations/:organizationId/audit-events" || + path === "/organizations/:organizationId/delivery-policy" || + path === "/organizations/:organizationId/teams/:teamId/esp-grant" || + path === + "/organizations/:organizationId/teams/:teamId/esp-grant/transition"; + + // These routes authorize either a human member or an organization + // key with the operation's required scope. Unknown organization routes + // default to user authentication until explicitly reviewed here. + return organizationKeyRoutes ? "userOrOrganization" : "user"; + } + + return "teamOrUser"; +} + export const openApiDocument = generateOpenApi( contract, { info: { title: "SendLit API", description: - "SendLit REST API for composing, sending and automating email. Authenticate with a team-scoped API key or a Better Auth OAuth2 bearer token.", + "SendLit REST API for composing, sending and automating email. Most team-scoped routes accept a team API key or Better Auth OAuth2 bearer token. Provisioning and selected organization routes accept scoped organization API keys; some routes require user authentication.", version: "0.1.0", }, servers: [ @@ -61,31 +166,67 @@ 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: { - apiKeyAuth: { + teamApiKey: { type: "apiKey", in: "header", name: "x-sendlit-apikey", + description: + "Team-scoped API key (sl_live_...) for REST requests.", }, - bearerAuth: { + userAccessToken: { type: "http", scheme: "bearer", bearerFormat: "JWT", + description: + "Better Auth OAuth access token for a user. Dashboard session cookies are also accepted on user-authenticated routes.", + }, + dashboardSession: { + type: "apiKey", + in: "cookie", + name: "sendlit.session_token", + description: + "Better Auth dashboard session cookie used for local HTTP connections.", + }, + dashboardSecureSession: { + type: "apiKey", + in: "cookie", + name: "__Secure-sendlit.session_token", + description: + "Better Auth dashboard session cookie used over HTTPS.", + }, + organizationApiKey: { + type: "http", + scheme: "bearer", + description: + "Scoped organization API key (sl_org_live_...) sent as Authorization: Bearer .", }, }, }, - security: [{ apiKeyAuth: [] }, { bearerAuth: [] }], + security: [{ teamApiKey: [] }, { userAccessToken: [] }], }, { operationMapper: (operation, route) => { + const authMode = getAuthMode(route.path, route.method); + const authDescription = `**Authentication:** ${ + authenticationDescriptions[authMode] + }`; return { ...operation, tags: (route.metadata as { tag?: string } | undefined)?.tag ? [(route.metadata as { tag: string }).tag] : operation.tags, - security: operation.security, + description: operation.description + ? `${operation.description}\n\n${authDescription}` + : authDescription, + security: securityByAuthMode[authMode], }; }, }, 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..196e5c1 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(), }; @@ -172,6 +188,8 @@ function serializeOrganizationKey( keyPrefix: key.keyPrefix, scopes: key.scopes as Array< | "organization:read" + | "delivery:read" + | "delivery:manage" | "teams:provision" | "teams:read" | "teams:manage" @@ -257,6 +275,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 +388,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 +403,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 +462,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 +515,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 +642,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 +698,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 +758,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 +832,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 +938,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 +1043,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 +1092,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 +1135,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, @@ -959,9 +1151,6 @@ const impl = s.router(contract.organizations, { if (result.noDestination) { return { status: 400, body: { error: result.error! } }; } - if (result.mailingAddressRequired) { - return { status: 422, body: { error: result.error! } }; - } if (!result.success) { return { status: 502, @@ -990,6 +1179,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 +1276,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 +1429,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 +1469,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, @@ -1369,7 +1570,10 @@ const impl = s.router(contract.organizations, { if (!authorization) { return { status: 404, body: { error: "organization_not_found" } }; } - if (!hasRole(authorization, ["owner", "admin"])) { + const mayRead = + hasRole(authorization, ["owner", "admin"]) || + Boolean(authorization.keyScopes?.includes("delivery:read")); + if (!mayRead) { return { status: 403, body: { error: "organization_permission_required" }, @@ -1394,12 +1598,19 @@ const impl = s.router(contract.organizations, { if (!authorization) { return { status: 404, body: { error: "organization_not_found" } }; } - if (!hasRole(authorization, ["owner", "admin"])) { + const mayManage = + hasRole(authorization, ["owner", "admin"]) || + Boolean(authorization.keyScopes?.includes("delivery:manage")); + if (!mayManage) { return { status: 403, 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 +1852,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 +1952,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/headless.routes.test.ts b/apps/api/src/provisioning/headless.routes.test.ts new file mode 100644 index 0000000..5794ddb --- /dev/null +++ b/apps/api/src/provisioning/headless.routes.test.ts @@ -0,0 +1,391 @@ +import express from "express"; +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authState = vi.hoisted(() => ({ + userId: "", + authKind: "organization_key" as string, + organizationId: "", + organizationApiKeyId: "org-key-test", + organizationScopes: [] as string[], +})); +const espTestMock = vi.hoisted(() => ({ testEspConfig: vi.fn() })); + +vi.mock("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); +vi.mock("../auth/middleware", () => ({ + requireAuth: (req: any, _res: any, next: () => void) => { + req.userId = authState.userId; + req.authKind = authState.authKind; + req.organizationId = authState.organizationId; + req.organizationApiKeyId = authState.organizationApiKeyId; + req.organizationScopes = authState.organizationScopes; + next(); + }, +})); +vi.mock("../settings/esp/test", () => ({ + testEspConfig: espTestMock.testEspConfig, +})); + +import { db } from "../db/client"; +import { + espConfigTeamGrants, + espConfigs, + organizationApiKeys, + organizationAuditEvents, + organizationDeliveryPolicies, + organizationMembers, + teamDeliverySettings, + teams, + user, +} from "../db/schema"; +import { truncateAll, type TestDb } from "../test/db"; +import { requestApp } from "../test/http"; +import { createOrganization } from "../organization/queries"; +import { createOrganizationEspConfig } from "../settings/esp/queries"; +import organizationRoutes from "../organization/routes"; +import provisioningRoutes from "./routes"; + +const tdb = db as unknown as TestDb; + +function app() { + const instance = express(); + instance.use(express.json()); + instance.use(organizationRoutes); + instance.use(provisioningRoutes); + return instance; +} + +async function insertUser(name: string) { + const [account] = await tdb + .insert(user) + .values({ + id: crypto.randomUUID(), + name, + email: `${name.toLowerCase()}-${crypto.randomUUID()}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + return account; +} + +async function createActiveOrganizationEsp(organizationId: string) { + const esp = await createOrganizationEspConfig(organizationId, { + name: "Shared SMTP", + provider: "smtp", + host: "smtp.example.com", + port: 587, + secure: false, + fromEmail: "mail@example.com", + }); + await tdb + .update(espConfigs) + .set({ status: "active" }) + .where(eq(espConfigs.id, esp.id)); + return esp; +} + +function policyPath(organizationId: string) { + return `/organizations/${organizationId}/delivery-policy`; +} + +beforeEach(async () => { + await truncateAll(tdb); + authState.userId = ""; + authState.authKind = "organization_key"; + authState.organizationId = ""; + authState.organizationApiKeyId = "org-key-test"; + authState.organizationScopes = []; + espTestMock.testEspConfig.mockReset().mockResolvedValue({ success: true }); +}); + +describe("headless organization provisioning", () => { + it("discovers only the organization bound to the calling key", async () => { + const owner = await insertUser("Owner"); + const org = await createOrganization(owner.id, "Acme"); + const otherOwner = await insertUser("Other"); + const otherOrg = await createOrganization(otherOwner.id, "Other Org"); + authState.organizationId = org.id; + authState.organizationScopes = ["organization:read"]; + + const response = await requestApp(app(), "/provisioning/organization"); + expect(response.status).toBe(200); + expect(response.json()).toMatchObject({ + organizationId: org.organizationId, + name: "Acme", + }); + expect(response.json().organizationId).not.toBe( + otherOrg.organizationId, + ); + + authState.organizationScopes = []; + const denied = await requestApp(app(), "/provisioning/organization"); + expect(denied.status).toBe(403); + }); + + it("requires delivery-specific scopes and enforces the key organization binding", async () => { + const owner = await insertUser("Owner"); + const org = await createOrganization(owner.id, "Acme"); + const otherOwner = await insertUser("Other"); + const otherOrg = await createOrganization(otherOwner.id, "Other Org"); + authState.organizationId = org.id; + authState.organizationScopes = ["esps:manage", "grants:manage"]; + + expect( + (await requestApp(app(), policyPath(org.organizationId))).status, + ).toBe(403); + const deniedWrite = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoGrantDefaultEsp: true }), + }, + ); + expect(deniedWrite.status).toBe(403); + + authState.organizationScopes = ["delivery:read"]; + expect( + (await requestApp(app(), policyPath(org.organizationId))).status, + ).toBe(200); + const deniedReadOnlyWrite = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoGrantDefaultEsp: true }), + }, + ); + expect(deniedReadOnlyWrite.status).toBe(403); + + authState.organizationScopes = ["delivery:manage"]; + const foreignBinding = await requestApp( + app(), + policyPath(otherOrg.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoGrantDefaultEsp: true }), + }, + ); + expect(foreignBinding.status).toBe(404); + }); + + it("allows an organization key to test an ESP with an explicit recipient", async () => { + const owner = await insertUser("Owner"); + const org = await createOrganization(owner.id, "Acme"); + const esp = await createOrganizationEspConfig(org.id, { + name: "Shared SMTP", + provider: "smtp", + host: "smtp.example.com", + port: 587, + secure: false, + fromEmail: "mail@example.com", + }); + authState.organizationId = org.id; + authState.organizationScopes = ["esps:manage"]; + + const response = await requestApp( + app(), + `/organizations/${org.organizationId}/esps/${esp.espId}/test`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: "operator@example.com" }), + }, + ); + + expect(response.status).toBe(200); + expect(espTestMock.testEspConfig).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ espId: esp.espId }), + to: "operator@example.com", + account: undefined, + }), + ); + }); + + it("sets policy as an organization key and grants the default to a newly provisioned team", async () => { + const owner = await insertUser("Owner"); + const org = await createOrganization(owner.id, "Acme"); + const esp = await createActiveOrganizationEsp(org.id); + authState.organizationId = org.id; + authState.organizationScopes = ["delivery:manage"]; + + const updated = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + defaultEspId: esp.espId, + autoGrantDefaultEsp: true, + }), + }, + ); + expect(updated.status).toBe(200); + expect(updated.json()).toMatchObject({ + defaultEspId: esp.espId, + autoGrantDefaultEsp: true, + }); + expect( + await tdb + .select() + .from(organizationApiKeys) + .where(eq(organizationApiKeys.organizationId, org.id)), + ).toHaveLength(0); + + const provisioned = await requestApp(app(), "/provisioning/teams", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + externalId: "courselit:school-123", + name: "School 123", + }), + }); + // Team provisioning remains a separate authority from delivery policy. + expect(provisioned.status).toBe(403); + + authState.organizationScopes = ["delivery:manage", "teams:provision"]; + const created = await requestApp(app(), "/provisioning/teams", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + externalId: "courselit:school-123", + name: "School 123", + }), + }); + expect(created.status).toBe(200); + expect(created.json()).toMatchObject({ + created: true, + name: "School 123", + }); + + const [policy] = await tdb + .select() + .from(organizationDeliveryPolicies) + .where(eq(organizationDeliveryPolicies.organizationId, org.id)); + expect(policy.defaultEspConfigId).toBe(esp.id); + + const [team] = await tdb + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.teamId, created.json().teamId)); + expect(team).toBeDefined(); + const [teamDelivery] = await tdb + .select({ settings: teamDeliverySettings }) + .from(teamDeliverySettings) + .where(eq(teamDeliverySettings.teamId, team.id)); + expect(teamDelivery.settings.defaultSource).toBe("organization"); + + const [grant] = await tdb + .select() + .from(espConfigTeamGrants) + .where(eq(espConfigTeamGrants.teamId, team.id)); + expect(grant).toMatchObject({ + organizationId: org.id, + espConfigId: esp.id, + status: "active", + }); + + const audit = await tdb + .select() + .from(organizationAuditEvents) + .where( + eq( + organizationAuditEvents.action, + "organization_delivery_policy.updated", + ), + ); + expect(audit).toEqual([ + expect.objectContaining({ + actorType: "organization_key", + actorId: authState.organizationApiKeyId, + }), + ]); + }); + + it("keeps owner/admin session access and rejects inactive or foreign ESP defaults", async () => { + const owner = await insertUser("Owner"); + const org = await createOrganization(owner.id, "Acme"); + const esp = await createActiveOrganizationEsp(org.id); + const otherOwner = await insertUser("Other"); + const otherOrg = await createOrganization(otherOwner.id, "Other Org"); + const foreignEsp = await createActiveOrganizationEsp(otherOrg.id); + + authState.authKind = "session"; + authState.userId = owner.id; + authState.organizationId = ""; + authState.organizationScopes = []; + expect( + (await requestApp(app(), policyPath(org.organizationId))).status, + ).toBe(200); + const ownerUpdate = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoGrantDefaultEsp: true }), + }, + ); + expect(ownerUpdate.status).toBe(200); + + const admin = await insertUser("Admin"); + await tdb.insert(organizationMembers).values({ + organizationId: org.id, + userId: admin.id, + role: "admin", + }); + authState.userId = admin.id; + expect( + (await requestApp(app(), policyPath(org.organizationId))).status, + ).toBe(200); + const adminUpdate = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ teamEspEnabledByDefault: false }), + }, + ); + expect(adminUpdate.status).toBe(200); + + authState.authKind = "organization_key"; + authState.organizationId = org.id; + authState.organizationScopes = ["delivery:manage"]; + const foreignUpdate = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ defaultEspId: foreignEsp.espId }), + }, + ); + expect(foreignUpdate.status).toBe(422); + + await tdb + .update(espConfigs) + .set({ status: "draft" }) + .where(eq(espConfigs.id, esp.id)); + const inactiveUpdate = await requestApp( + app(), + policyPath(org.organizationId), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ defaultEspId: esp.espId }), + }, + ); + expect(inactiveUpdate.status).toBe(422); + }); +}); diff --git a/apps/api/src/provisioning/routes.ts b/apps/api/src/provisioning/routes.ts index 28f8b8b..b8822fe 100644 --- a/apps/api/src/provisioning/routes.ts +++ b/apps/api/src/provisioning/routes.ts @@ -20,6 +20,12 @@ import logger from "../services/log"; import { captureError, captureEvent } from "../observability/posthog"; import { requireAuth } from "../auth/middleware"; import { recordOrganizationAuditEvent } from "../organization/audit"; +import { getOrganization } from "../organization/queries"; +import { + assertCapability, + getOrganizationEntitlements, +} from "../billing/entitlements"; +import { planGateHttp } from "../billing/errors"; const router = Router(); @@ -41,6 +47,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" && @@ -91,6 +104,40 @@ async function auditProvisioningAction( * organization is always derived from the key, never request input. */ const impl = s.router(contract.provisioning, { + getOrganization: async ({ req }) => { + const authReq = req as any; + if (authReq.authKind !== "organization_key") { + return { + status: 403, + body: { error: "organization_key_required" }, + }; + } + if (!hasScope(authReq, "organization:read")) { + return { + status: 403, + body: { error: "organization_scope_required" }, + }; + } + const organization = await getOrganization(authReq.organizationId); + if (!organization) { + return { status: 404, body: { error: "organization_not_found" } }; + } + return { + status: 200, + body: { + organizationId: organization.organizationId, + name: organization.name, + status: organization.status as + | "pending_payment" + | "active" + | "suspended" + | "abandoned" + | "closed", + createdAt: organization.createdAt.toISOString(), + updatedAt: organization.updatedAt.toISOString(), + }, + }; + }, provisionTeam: async ({ body, req }) => { const authReq = req as any; if (authReq.authKind !== "organization_key") { @@ -166,6 +213,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 +254,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 +266,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 +280,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 +334,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 +367,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 +397,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.test.ts b/apps/api/src/sequences/queries.test.ts index c4512e9..eb0fef8 100644 --- a/apps/api/src/sequences/queries.test.ts +++ b/apps/api/src/sequences/queries.test.ts @@ -24,6 +24,7 @@ import { espConfigs, media, mediaReferences, + ongoingSequences, rules, sequences, sequenceEmails, @@ -46,6 +47,7 @@ import { getSequenceOpenRate, getSubscribers, getSubscribersCount, + listSequences, pauseSequence, startSequence, updateMailInSequence, @@ -81,6 +83,31 @@ function emailWithImage(mediaId: string): Email { } describe("sequence queries", () => { + it("includes the active entrant count when listing sequences", async () => { + const { team, contact } = await seedTeamAndContact(tdb); + const template = await makeTemplate(team.id); + const sequence = await createSequence({ + teamId: team.id, + type: "sequence", + templateId: template.templateId, + }); + + await tdb.insert(ongoingSequences).values({ + teamId: team.id, + sequenceId: sequence.id, + contactId: contact.id, + nextEmailScheduledTime: Date.now(), + }); + + const items = await listSequences({ + teamId: team.id, + type: "sequence", + }); + expect( + items.find((item) => item.sequenceId === sequence.sequenceId), + ).toMatchObject({ entrantsCount: 1 }); + }); + it("creates a draft sequence from a team-owned template and adds/removes mails safely", async () => { const { team } = await seedTeamAndContact(tdb); const template = await makeTemplate(team.id); diff --git a/apps/api/src/sequences/queries.ts b/apps/api/src/sequences/queries.ts index f1b7105..19d5ff3 100644 --- a/apps/api/src/sequences/queries.ts +++ b/apps/api/src/sequences/queries.ts @@ -1,8 +1,9 @@ -import { and, eq, count, asc } from "drizzle-orm"; +import { and, eq, count, asc, inArray } from "drizzle-orm"; import { db } from "../db/client"; import { sequences, sequenceEmails, + ongoingSequences, emailDeliveries, emailEvents, contacts, @@ -36,12 +37,21 @@ 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; export type HydratedSequence = Sequence & { emails: SequenceEmail[]; deliverySource: DeliverySourceSelection | null; + entrantsCount?: number; }; function clearDeletedDeliverySourceMarker( @@ -236,7 +246,31 @@ export async function listSequences({ .where(and(eq(sequences.teamId, teamId), eq(sequences.type, type))) .limit(pageSize) .offset((Math.max(offset, 1) - 1) * pageSize); - return Promise.all(rows.map(hydrate)); + if (rows.length === 0) return []; + + const activeEntrants = await db + .select({ sequenceId: ongoingSequences.sequenceId, value: count() }) + .from(ongoingSequences) + .where( + inArray( + ongoingSequences.sequenceId, + rows.map((row) => row.id), + ), + ) + .groupBy(ongoingSequences.sequenceId); + const activeEntrantsBySequenceId = new Map( + activeEntrants.map((row) => [row.sequenceId, row.value]), + ); + + return Promise.all( + rows.map(async (row) => ({ + ...(await hydrate(row)), + entrantsCount: + row.type === "broadcast" + ? row.entrants.length + : (activeEntrantsBySequenceId.get(row.id) ?? 0), + })), + ); } export async function countSequences( @@ -618,10 +652,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 +697,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..1018c36 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,11 +178,13 @@ 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, body: { - error: "A mailing address is required before sending email.", + error: "A mailing address is required for broadcasts and sequences.", }, } : err.message === "esp_not_configured" diff --git a/apps/api/src/settings/esp/routes.ts b/apps/api/src/settings/esp/routes.ts index 25af3f0..2c65270 100644 --- a/apps/api/src/settings/esp/routes.ts +++ b/apps/api/src/settings/esp/routes.ts @@ -95,9 +95,6 @@ async function testConfig({ if (result.noDestination) { return { status: 400 as const, body: { error: result.error! } }; } - if (result.mailingAddressRequired) { - return { status: 422 as const, body: { error: result.error! } }; - } if (!result.success) { return { status: 502 as const, diff --git a/apps/api/src/settings/esp/test.ts b/apps/api/src/settings/esp/test.ts index 66cea2b..2e584cc 100644 --- a/apps/api/src/settings/esp/test.ts +++ b/apps/api/src/settings/esp/test.ts @@ -6,7 +6,6 @@ import { recordEspTestResult, type EspConfig, } from "./queries"; -import { MAILING_ADDRESS_REQUIRED } from "../general/constants"; export interface TestEspConfigResult { success: boolean; @@ -15,8 +14,6 @@ export interface TestEspConfigResult { * from a delivery failure so callers (REST `400` vs `502`, MCP) can tell * "nothing to send to" apart from "the ESP rejected the send". */ noDestination?: boolean; - /** Set when the team has not configured its required physical address. */ - mailingAddressRequired?: boolean; } /** Sends a real test email through `config`'s transport and records the @@ -96,13 +93,6 @@ export async function testEspConfig({ }); return { success: true }; } catch (err: any) { - if (err.message === MAILING_ADDRESS_REQUIRED) { - return { - success: false, - error: "A mailing address is required before sending email.", - mailingAddressRequired: true, - }; - } await recordEspTestResult( config.ownerScope === "organization" ? config.organizationId! diff --git a/apps/api/src/settings/general/queries.ts b/apps/api/src/settings/general/queries.ts index ccb65df..71b1f6f 100644 --- a/apps/api/src/settings/general/queries.ts +++ b/apps/api/src/settings/general/queries.ts @@ -18,11 +18,7 @@ export interface GeneralSettings { updatedAt: Date | null; } -/** - * A physical mailing address is a compliance prerequisite for every team - * email. Keep this check close to the settings source so all send paths use - * the same definition (a whitespace-only value is not configured). - */ +/** A physical mailing address is required for marketing campaign delivery. */ export async function assertMailingAddressConfigured( teamId: string, ): Promise { diff --git a/apps/api/src/team/queries.ts b/apps/api/src/team/queries.ts index f5c0a1d..93ce7f6 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"; @@ -210,8 +215,8 @@ export async function getProvisionedTeamView(team: Team) { * * `withDefaultApiKey` opts into also minting a "Default" API key for the new * team — only worth doing when the caller has an actual way to hand the - * one-time secret to whoever needs it (provisioning's response body, - * bootstrap's startup log). Dashboard/MCP-driven team creation has no such + * one-time secret to whoever needs it (provisioning's response body). + * Dashboard/MCP-driven team creation has no such * surface, so it defaults to `false`: better to have the user mint a key * explicitly (and see it) than to silently burn one they'll never see. */ @@ -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..462c118 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,12 +58,27 @@ 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); + // A policy may point at an organization ESP; clear the reference before + // truncating ESP rows to preserve the composite foreign-key invariant. + await db.delete(schema.organizationDeliveryPolicies); 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); - await db.delete(schema.organizationDeliveryPolicies); await db.delete(schema.organizations); await db.delete(schema.user); } @@ -107,6 +125,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.test.ts b/apps/api/src/transactional/queries.test.ts index d0dd018..44cf56a 100644 --- a/apps/api/src/transactional/queries.test.ts +++ b/apps/api/src/transactional/queries.test.ts @@ -452,6 +452,24 @@ describe("createTransactionalEmail happy paths", () => { expect(row.html).toBe("

Hello {{ not_a_liquid_tag }}

"); }); + it("accepts transactional sends without a workspace mailing address", async () => { + const { team } = await seedTeamAndContact(tdb, { + settings: { mailingAddress: null }, + }); + + const row = await createTransactionalEmail({ + teamId: team.id, + to: "owner@example.com", + subject: "Your sign-in code", + html: "

123456

", + }); + + expect(row.status).toBe("queued"); + expect(await db.select().from(schema.mailDispatchOutbox)).toHaveLength( + 1, + ); + }); + it("associates the row with an existing contact by email, purely for analytics", async () => { const { team, contact } = await seedTeamAndContact(tdb); diff --git a/apps/api/src/transactional/queries.ts b/apps/api/src/transactional/queries.ts index d4230e6..3860987 100644 --- a/apps/api/src/transactional/queries.ts +++ b/apps/api/src/transactional/queries.ts @@ -7,7 +7,6 @@ import { transactionalEmails, } from "../db/schema"; import { getTeam } from "../team/queries"; -import { assertMailingAddressConfigured } from "../settings/general/queries"; import { getTemplate } from "../templates/queries"; import { findContactByEmail } from "../contacts/queries"; import { @@ -31,6 +30,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 }; @@ -223,10 +223,6 @@ export async function createTransactionalEmail({ trackClicks?: boolean; deliverySource?: DeliverySourceSelection; }): Promise { - // Reject before persisting or queueing so an unconfigured team cannot - // create delivery work that would later fail in a worker. - await assertMailingAddressConfigured(teamId); - if (!!templateId === !!html) { throw new Error("invalid_content"); } @@ -268,7 +264,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 +403,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..f943f42 100644 --- a/apps/api/src/transactional/routes.ts +++ b/apps/api/src/transactional/routes.ts @@ -11,11 +11,11 @@ import { listTransactionalEmails, toPublicTransactionalEmail, } from "./queries"; -import { MAILING_ADDRESS_REQUIRED } from "../settings/general/constants"; import { MISSING_TEMPLATE_VARIABLES, MissingTemplateVariablesError, } from "../mail/render"; +import { isPlanGateError } from "../billing/errors"; const router = Router(); router.use("/emails", requireAuth, requireTeam); @@ -86,6 +86,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, @@ -140,13 +146,6 @@ const impl = s.router(contract.transactional, { status: 422, body: { error: "Team ESP is not configured." }, }; - case MAILING_ADDRESS_REQUIRED: - return { - status: 422, - body: { - error: "A mailing address is required before sending email.", - }, - }; case "esp_not_found": return { status: 422, body: { error: "ESP not found" } }; case "recipient_suppressed": diff --git a/apps/docs/.source/browser.ts b/apps/docs/.source/browser.ts index e98216e..a2b28f2 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/headless-provisioning.mdx": () => import("../content/docs/developers/headless-provisioning.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/api-keys.mdx b/apps/docs/content/docs/developers/api-keys.mdx index 146a18e..427f63d 100644 --- a/apps/docs/content/docs/developers/api-keys.mdx +++ b/apps/docs/content/docs/developers/api-keys.mdx @@ -3,7 +3,8 @@ title: API keys description: Authenticate team-scoped server-to-server integrations. --- -Create and revoke keys from the dashboard’s Teams page or the Teams API. Send a key with the `x-sendlit-apikey` header: +This page covers team API keys. Create and revoke them from the dashboard’s +Teams page or the Teams API. Send one with the `x-sendlit-apikey` header: ```bash curl https://api.sendlit.app/contacts \ @@ -11,3 +12,9 @@ curl https://api.sendlit.app/contacts \ ``` Keys are hashed at rest and the complete secret is returned only by the create operation. Store it in a secret manager, never commit it, and rotate it after suspected exposure. A key resolves exactly one team; pass `X-Sendlit-Team-Id` only when the authenticated identity can access multiple teams and the endpoint supports explicit team selection. + +Organization API keys use the `sl_org_live_...` prefix and the Bearer +authorization header. They are independently scoped and are not accepted by +MCP. See [Headless organization setup](/developers/headless-provisioning) for +the self-host secret-file handoff and +[Provisioning teams](/developers/provisioning) for runtime team provisioning. diff --git a/apps/docs/content/docs/developers/authentication.mdx b/apps/docs/content/docs/developers/authentication.mdx index fa7989e..b3dac60 100644 --- a/apps/docs/content/docs/developers/authentication.mdx +++ b/apps/docs/content/docs/developers/authentication.mdx @@ -6,8 +6,9 @@ description: OAuth2, organization keys, and team API keys. SendLit supports OAuth2, organization API keys, and team API keys. Use OAuth2 for user-authorized app integrations. Use a team API key for -team-scoped server-to-server integrations. Use an organization API key for -trusted platform provisioning and organization administration. +team-scoped server-to-server integrations. Use a scoped organization API key +for trusted platform team provisioning or headless organization setup through +the REST API. ## OAuth2 for user-authorized integrations @@ -44,6 +45,12 @@ Keys are stored hashed (SHA-256), which means: If a key is lost or leaked, revoke it and create a new one. +Organization keys use the `sl_org_live_...` prefix and are sent with +`Authorization: Bearer `. Their permissions are explicitly +scoped; see [Headless organization setup](/developers/headless-provisioning) +for discovery, shared-delivery setup, and key roles. An organization key does +not authenticate to MCP, which remains team-scoped. + ## Dashboard sign-in The web dashboard uses email OTP sign-in. The session is separate from team API keys: use the dashboard session for interactive administration and an API key for server-to-server requests. 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/headless-provisioning.mdx b/apps/docs/content/docs/developers/headless-provisioning.mdx new file mode 100644 index 0000000..39214b4 --- /dev/null +++ b/apps/docs/content/docs/developers/headless-provisioning.mdx @@ -0,0 +1,305 @@ +--- +title: Headless organization setup +description: Configure shared delivery once, then provision teams with a least-privilege organization key. +--- + +This guide configures shared delivery without a dashboard session or a key +copied from startup logs. It separates a short-lived delivery setup key from +the narrower key used by integrations such as CourseLit to provision teams. + +The complete plain Markdown version is in the repository at +[`apps/docs/integrations/headless-provisioning.md`](https://github.com/codelitdev/sendlit/blob/main/apps/docs/integrations/headless-provisioning.md). +The machine-readable REST contract is served at `GET /openapi.json`; Swagger +UI is at `GET /docs`. + +## Credentials + +Organization API keys are sent as `Authorization: Bearer `. Keep them in +a server-side secret manager or deployment environment, never in browser or +mobile code. + +| Key | Bootstrap display name | Scopes | Use | +| ----------------- | ----------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------- | +| Delivery setup | `[Auto-generated] Delivery Setup` | `organization:read`, `esps:read`, `esps:manage`, `delivery:read`, `delivery:manage` | One-time ESP and delivery-policy configuration | +| Team provisioning | `[Auto-generated] Team Provisioning` | `organization:read`, `teams:provision`, `teams:read` | Ongoing server-to-server team provisioning | + +`delivery:manage` authorizes every field in the delivery-policy update schema, +including shared quotas and team-delivery controls—not only the default ESP +and automatic grants. Revoke the setup key after use if no further policy +automation is needed. + +## Configure bootstrap keys + +Set `BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` to the initial owner. It selects an +ordinary organization owner; it does not grant a special global-admin role. +Bootstrap finds or creates that user's default organization and registers +configured keys even when the user already exists. If the user is not an +owner of that organization, key registration fails closed; bootstrap does not +promote a member or administrator automatically. + +Create two distinct high-entropy organization keys and put both in the SendLit +Compose `.env`, alongside the other deployment secrets. Generate one value +per key with: + +```sh +node -e 'process.stdout.write(`sl_org_live_${require("node:crypto").randomBytes(32).toString("base64url")}\n`)' +``` + +Copy one generated value to each variable: + +```dotenv +BOOTSTRAP_ORGANIZATION_OWNER_EMAIL=admin@example.com +BOOTSTRAP_DELIVERY_SETUP_API_KEY=sl_org_live_ +BOOTSTRAP_TEAM_PROVISIONING_API_KEY=sl_org_live_ +``` + +Keep both values server-side. Compose passes them to the one-shot `init` +service to register their hashes, then explicitly clears them in the +long-running API and web containers. Give the delivery key only to the +one-shot delivery setup client. Give CourseLit the team-provisioning key in +its server-side runtime environment. Never expose either key in browser or +mobile code. + +Bootstrap stores only a hash and display prefix. Re-running with the same +active key, organization, and exact scopes is a no-op. Invalid-format, +revoked, expired, cross-organization, or differently scoped keys fail closed; +bootstrap will not recreate a revoked key or expand its scopes. After revoking +the delivery key, clear `BOOTSTRAP_DELIVERY_SETUP_API_KEY` before the next +bootstrap run. + +## Configure shared delivery once + +Run delivery setup as an explicit one-time job; do not reset the policy on +every stack startup. Reuse the intended ESP by its stored public ID. ESP names +are not unique, so fail clearly if a name matches multiple records. + +The examples use generic SMTP settings. Choose host, port, TLS, credentials, +and recipient for your deployment. These are normal API fields, not Compose- +specific behavior. + +```sh +SENDLIT_API_URL=https://api.sendlit.example +: "${BOOTSTRAP_DELIVERY_SETUP_API_KEY:?Load it from the SendLit .env before running this script}" +: "${BOOTSTRAP_TEAM_PROVISIONING_API_KEY:?Load it from the integration environment}" +TEST_RECIPIENT_EMAIL=operator@example.com +``` + +Discover the organization bound to the key. The endpoint has no organization +ID input and returns only that key's organization: + +```sh +ORGANIZATION_RESPONSE="$(curl --silent --show-error --fail-with-body \ + "$SENDLIT_API_URL/provisioning/organization" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY")" +ORGANIZATION_ID="$(printf '%s' "$ORGANIZATION_RESPONSE" | jq -er '.organizationId')" +printf '%s\n' "$ORGANIZATION_RESPONSE" | jq . +``` + +Example response: + +```json +{ + "organizationId": "org_01J...", + "name": "Example Organization", + "status": "active", + "createdAt": "2026-09-01T10:00:00.000Z", + "updatedAt": "2026-09-01T10:00:00.000Z" +} +``` + +Use its public `organizationId` for organization routes. + +List ESPs with `esps:read` or `esps:manage`, then reuse the desired `espId` or +create an organization-owned ESP: + +```sh +curl --fail-with-body \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" +``` + +Reuse a stored `espId` if exactly one ESP is intended. Otherwise create the +ESP and capture its public ID: + +```sh +ESP_RESPONSE="$(curl --silent --show-error --fail-with-body --request POST \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" \ + -H 'Content-Type: application/json' \ + --data '{ + "name": "Shared delivery", + "provider": "smtp", + "host": "smtp.example.net", + "port": 587, + "secure": true, + "username": "smtp-user", + "password": "", + "fromName": "Example", + "fromEmail": "mail@example.com" + }')" +ESP_ID="$(printf '%s' "$ESP_RESPONSE" | jq -er '.espId')" +printf 'Using ESP %s\n' "$ESP_ID" +``` + +The response contains `espId` and starts with `status: "draft"`; the encrypted +provider secret is never returned. Send a test with an explicit recipient +because an organization API key has no signed-in user email fallback: + +```json +{ + "espId": "esp_01J...", + "name": "Shared delivery", + "provider": "smtp", + "host": "smtp.example.net", + "port": 587, + "secure": true, + "username": "smtp-user", + "hasPassword": true, + "fromName": "Example", + "fromEmail": "mail@example.com", + "status": "draft", + "secretVersion": 1, + "lastTestedAt": null, + "lastTestStatus": null, + "lastTestError": null, + "activatedAt": null, + "drainUntil": null, + "retiredAt": null, + "updatedAt": "2026-09-01T10:02:00.000Z" +} +``` + +```sh +curl --fail-with-body --request POST \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps/$ESP_ID/test" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" \ + -H 'Content-Type: application/json' \ + --data "{\"to\":\"$TEST_RECIPIENT_EMAIL\"}" +``` + +Successful test response: + +```json +{ "success": true } +``` + +After a successful test, activate the ESP: + +```sh +curl --fail-with-body --request POST \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps/$ESP_ID/activate" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" +``` + +The response is the ESP projection with `status: "active"` and its public +`espId`. + +Set it as the default for new teams and enable automatic grants: + +```sh +curl --fail-with-body --request PUT \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/delivery-policy" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" \ + -H 'Content-Type: application/json' \ + --data "{\"defaultEspId\":\"$ESP_ID\",\"autoGrantDefaultEsp\":true}" +``` + +The policy response includes `defaultEspId`, `autoGrantDefaultEsp`, nullable +quota fields, team-delivery defaults, and `updatedAt`, for example: + +```json +{ + "defaultEspId": "esp_01J...", + "autoGrantDefaultEsp": true, + "defaultDailyLimit": null, + "defaultMonthlyLimit": null, + "aggregateDailyLimit": null, + "aggregateMonthlyLimit": null, + "teamEspEnabledByDefault": true, + "teamCanChangeDefault": true, + "updatedAt": "2026-09-01T10:05:00.000Z" +} +``` + +The API checks that the selected ESP belongs to the organization and is +active. Owner/admin dashboard sessions remain supported. Organization keys +need `delivery:read` to read this policy and `delivery:manage` to update it; +ESP or grant scopes do not imply policy authority. + +Before writing the policy, inspect it with the setup key: + +```sh +curl --fail-with-body \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/delivery-policy" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" +``` + +If a policy is already configured, do not overwrite an operator's later +choices on every application restart. Run setup as an explicit one-shot job +or record a completion marker in the consuming integration; reruns should +confirm the intended ESP ID and skip the write when setup is complete. + +Revoke the setup key after successful setup if you do not need further policy +automation. Clear `BOOTSTRAP_DELIVERY_SETUP_API_KEY` before the next SendLit +bootstrap run. + +## Provision teams with the runtime key + +CourseLit should use only the team-provisioning key at runtime. It does not +need ESP management, grant management, or delivery-policy authority. Provision +one team per stable external tenant ID: + +```sh +curl --fail-with-body --request POST \ + "$SENDLIT_API_URL/provisioning/teams" \ + -H "Authorization: Bearer $BOOTSTRAP_TEAM_PROVISIONING_API_KEY" \ + -H 'Content-Type: application/json' \ + --data '{ + "externalId": "school:school-123", + "name": "School 123", + "sender": { "fromName": "School 123" }, + "mailingAddress": "123 Example Street, Example City", + "delivery": { "useOrganizationDefault": true } + }' +``` + +The first response has `created: true` and returns the team API key in +`apiKey`; persist it securely if the integration sends via team-scoped APIs. +An identical retry for the same `(organization, externalId)` returns +`created: false` and `apiKey: null`. With automatic grants enabled, the new +team receives the active organization ESP grant and uses it as its default. +First creation returns: + +```json +{ + "teamId": "team_01J...", + "externalId": "school:school-123", + "name": "School 123", + "deliverySource": { "type": "organization" }, + "created": true, + "apiKey": "sl_live_..." +} +``` + +Persist the one-time team key immediately. An identical replay returns +`"created": false` and `"apiKey": null`. +See [Provisioning teams](/developers/provisioning) for lifecycle and retry +details. + +## Rotation, migration, and agent clients + +Rotate by registering a new key, switching the consumer, verifying it, and +then explicitly revoking the old key. Bootstrap does not revoke older keys. +For deployments using the old key printed in `init` logs, keep it active while +registering and verifying the new team-provisioning key. Switch CourseLit, +then revoke the old broad key from the dashboard or authenticated API. + +Use `/openapi.json` as the machine-readable API source for SDKs and agents. +Organization provisioning and shared-delivery setup use REST; organization +keys do not authenticate to MCP. MCP remains team-scoped and accepts team keys +or user OAuth. + +The local Compose/Mailpit transport initializer is a separate integration +concern. It owns local SMTP host configuration, service health checks, and a +configurable test recipient; SendLit’s general REST API does not special-case +Mailpit. diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index 2a42607..35f6fdb 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -44,7 +44,11 @@ OAuth authorization, consent, token, introspection, revocation, and userinfo end Use Authorization Code with S256 PKCE, request only the scopes your client needs, and keep any refresh token in the client's secure credential store. The authorization request must include an explicit, non-empty `scope`; SendLit does not turn a missing value into a full-access grant. Every authorization requires a SendLit user to sign in, select a team when necessary, and approve consent. -API keys always select one fixed team and currently grant the complete MCP tool set for that team. Dashboard session cookies and `X-Sendlit-Team-Id` are not accepted as MCP authentication. +Team API keys always select one fixed team and currently grant the complete +MCP tool set for that team. Organization API keys, dashboard session cookies, +and `X-Sendlit-Team-Id` are not accepted as MCP authentication. Use the REST +API for organization discovery, shared-delivery setup, and team provisioning; +see [Headless organization setup](/developers/headless-provisioning). OAuth clients (including MCP clients like Claude) have no way to send a custom header during authorization, so a multi-team account instead picks a team as part of the OAuth flow itself: after login, and before the consent screen, the account is shown a "select a team" step (mirroring how apps like Notion resolve their own multi-workspace ambiguity). The chosen team is baked into the issued access token and used for every subsequent request — no header needed. Accounts with exactly one team skip this step entirely. @@ -128,8 +132,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/meta.json b/apps/docs/content/docs/developers/meta.json index bb7787e..27fdae5 100644 --- a/apps/docs/content/docs/developers/meta.json +++ b/apps/docs/content/docs/developers/meta.json @@ -8,6 +8,7 @@ "errors", "mcp", "transactional", - "provisioning" + "provisioning", + "headless-provisioning" ] } diff --git a/apps/docs/content/docs/developers/provisioning.mdx b/apps/docs/content/docs/developers/provisioning.mdx index f35d47e..d92743e 100644 --- a/apps/docs/content/docs/developers/provisioning.mdx +++ b/apps/docs/content/docs/developers/provisioning.mdx @@ -12,6 +12,17 @@ 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. +For an entirely headless setup—including discovering the key's organization, +configuring an organization ESP, and setting shared delivery for new teams—use +the [Headless organization setup](/developers/headless-provisioning) guide. + +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 @@ -40,7 +51,9 @@ one team's contacts and email APIs. Keep these credentials separate. - For customer-owned delivery, enable team ESPs. The provisioned team can configure its own provider later using its team key or the dashboard. 3. Create an organization API key and store its plaintext value in your - backend secret manager. It is displayed only once. + backend secret manager. It is displayed only once. For a team-provisioning + integration, use `organization:read`, `teams:provision`, and `teams:read`; + do not give its runtime key delivery administration scopes. Use the minimum scopes your integration needs: @@ -52,6 +65,10 @@ Use the minimum scopes your integration needs: | `teams:keys` | Replace a provisioned team's integration key | | `usage:read` | Read team quota usage | +`organization:read` also enables `GET /provisioning/organization`, which +returns the organization bound to the key. One-time shared-delivery setup uses +a separate key; see the headless setup guide for its scopes and lifecycle. + The examples below use: ```bash diff --git a/apps/docs/content/docs/developers/transactional.mdx b/apps/docs/content/docs/developers/transactional.mdx index f6ce175..1ae5104 100644 --- a/apps/docs/content/docs/developers/transactional.mdx +++ b/apps/docs/content/docs/developers/transactional.mdx @@ -8,10 +8,10 @@ password reset, OTP, or security notification. Transactional sends appear in the dashboard’s **Transactional log**. They do not enroll a contact in a sequence or use a broadcast audience. -A non-empty workspace mailing address and a configured ESP are still required -before SendLit accepts any send. This workspace policy is separate from message -content: transactional templates do not render a marketing footer or -unsubscribe link. +A configured ESP is required before SendLit accepts a send. A workspace mailing +address is not required for transactional messages: they do not render a +marketing footer or unsubscribe link. The address remains required for +broadcasts and sequences, whose managed footer includes it. ## Create a transactional template 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..a0bc926 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. @@ -151,13 +157,15 @@ can use that mailbox. ## Provision teams from your platform For a CourseLit-style integration, set up the organization once in the -dashboard: +dashboard or follow the [headless organization setup](/developers/headless-provisioning) +guide: 1. Create or select the organization. 2. Add, test, and activate a shared mailbox. 3. Set the default-delivery policy and any quotas. -4. Create an organization API key with the `teams:provision` scope. Store the - secret in your platform’s server-side secret manager; it is shown once. +4. Create a runtime organization API key with `organization:read`, + `teams:provision`, and `teams:read`. Store it in your platform’s server-side + secret manager and do not include delivery-administration scopes. 5. On each customer signup, your backend calls `POST /provisioning/teams` with `Authorization: Bearer `, a stable `externalId`, and the team’s sender, delivery, and quota settings. 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/docs/content/docs/workspace/settings.mdx b/apps/docs/content/docs/workspace/settings.mdx index 4d59632..b08001b 100644 --- a/apps/docs/content/docs/workspace/settings.mdx +++ b/apps/docs/content/docs/workspace/settings.mdx @@ -5,7 +5,7 @@ description: Configure sending identity and email service providers. Settings has two team-scoped areas: -- **General** stores the team mailing address used for sender compliance and unsubscribe-related email requirements. +- **General** stores the team mailing address used in the managed marketing footer and required for broadcasts and sequences. - **ESP** configures team-owned email service providers and can make one the team's default. diff --git a/apps/docs/content/docs/workspace/teams.mdx b/apps/docs/content/docs/workspace/teams.mdx index af4400b..d34c373 100644 --- a/apps/docs/content/docs/workspace/teams.mdx +++ b/apps/docs/content/docs/workspace/teams.mdx @@ -32,6 +32,8 @@ provisioning. Manage keys from the Teams page or through the Teams API. Keys are team-scoped and shown in full only once when created. Store the secret immediately; if it is lost or exposed, revoke it and create a replacement. -For multi-tenant provisioning, use an organization API key with the -`teams:provision` scope. It is sent as a Bearer token to the provisioning API; -the old shared provisioning secret is not used. +For multi-tenant provisioning, use an organization API key with +`organization:read`, `teams:provision`, and `teams:read`. It is sent as a +Bearer token to the provisioning API; the old shared provisioning secret is +not used. See [Headless organization setup](/developers/headless-provisioning) +for the complete setup and key handoff. diff --git a/apps/docs/integrations/headless-provisioning.md b/apps/docs/integrations/headless-provisioning.md new file mode 100644 index 0000000..f5fc6b7 --- /dev/null +++ b/apps/docs/integrations/headless-provisioning.md @@ -0,0 +1,312 @@ +# Headless organization setup and team provisioning + +This guide configures shared delivery once, then gives an integration such as +CourseLit a separate, narrower key for ongoing team provisioning. The workflow +uses supported REST APIs and works without a dashboard session or copying a +generated key from startup logs. + +For the rendered version, see [Headless provisioning](/developers/headless-provisioning). +The machine-readable API contract is available at `GET /openapi.json`; Swagger +UI is at `GET /docs`. + +## Credentials and scopes + +Organization keys are sent as `Authorization: Bearer `. Keep them in a +server-side secret manager or deployment environment; never put them in +browser or mobile code. + +| Key | Bootstrap display name | Scopes | Use | +| ----------------- | ------------------------------------ | ----------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Delivery setup | `[Auto-generated] Delivery Setup` | `organization:read`, `esps:read`, `esps:manage`, `delivery:read`, `delivery:manage` | One-time shared ESP and delivery-policy configuration | +| Team provisioning | `[Auto-generated] Team Provisioning` | `organization:read`, `teams:provision`, `teams:read` | Ongoing server-to-server team provisioning | + +`delivery:manage` authorizes every field in the delivery-policy update schema, +including shared quotas and team delivery controls—not only the default ESP +and automatic grants. Use it only for trusted setup automation and revoke it +after setup if it is no longer needed. + +## Register configured keys in a self-hosted install + +Set `BOOTSTRAP_ORGANIZATION_OWNER_EMAIL` to the initial owner's email. It selects +an ordinary organization owner; it does not create a special global-admin +role. Bootstrap finds or creates that user's default organization and +registers any configured keys even when the user already exists. If the user +is not an owner of that organization, key registration fails closed rather +than promoting a member or administrator automatically. + +Generate two distinct high-entropy organization keys. For each key, run: + +```sh +node -e 'process.stdout.write(`sl_org_live_${require("node:crypto").randomBytes(32).toString("base64url")}\n`)' +``` + +Copy the generated values to the SendLit Compose `.env`: + +```dotenv +BOOTSTRAP_ORGANIZATION_OWNER_EMAIL=admin@example.com +BOOTSTRAP_DELIVERY_SETUP_API_KEY=sl_org_live_ +BOOTSTRAP_TEAM_PROVISIONING_API_KEY=sl_org_live_ +``` + +Keep both values server-side. The root Compose file passes them only to the +one-shot `init` service and explicitly clears them in the long-running API and +web containers. Pass the delivery key to the one-shot setup client and the +team-provisioning key to the integration's server-side runtime environment. +Never expose either key to browser or mobile clients. + +On each bootstrap run, SendLit stores only a SHA-256 hash and display prefix. +The same active key, organization, and exact scopes are an idempotent no-op. A +revoked, expired, cross-organization, invalid-format, or differently scoped +key fails closed; bootstrap will not recreate it or widen its scopes. If the +delivery key has been revoked, unset +`BOOTSTRAP_DELIVERY_SETUP_API_KEY` before the next bootstrap. Keep the +team-provisioning path configured while the integration uses that key. + +## Configure shared delivery once + +The delivery setup service/job should run explicitly, not on every stack +startup. It should preserve operator changes on reruns. Store the chosen ESP +ID or fail clearly if a configured ESP name matches multiple records; names +are not unique. + +Set variables for your API origin, key, and test recipient. The examples below +use a generic SMTP provider; transport host, port, TLS, and credentials are +deployment-specific. + +```sh +SENDLIT_API_URL=https://api.sendlit.example +: "${BOOTSTRAP_DELIVERY_SETUP_API_KEY:?Load it from the SendLit .env before running this script}" +: "${BOOTSTRAP_TEAM_PROVISIONING_API_KEY:?Load it from the integration environment}" +TEST_RECIPIENT_EMAIL=operator@example.com +``` + +Discover the organization bound to the key. The endpoint has no organization +ID input and returns only the caller key's organization: + +```sh +ORGANIZATION_RESPONSE="$(curl --silent --show-error --fail-with-body \ + "$SENDLIT_API_URL/provisioning/organization" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY")" +ORGANIZATION_ID="$(printf '%s' "$ORGANIZATION_RESPONSE" | jq -er '.organizationId')" +printf '%s\n' "$ORGANIZATION_RESPONSE" | jq . +``` + +Example response: + +```json +{ + "organizationId": "org_01J...", + "name": "Example Organization", + "status": "active", + "createdAt": "2026-09-01T10:00:00.000Z", + "updatedAt": "2026-09-01T10:00:00.000Z" +} +``` + +List organization ESPs with `esps:read` or `esps:manage` and reuse the +intended ESP by its stored public `espId`. If none exists, create one: + +```sh +curl --fail-with-body \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" +``` + +For an unambiguous existing ESP, load its previously stored `espId` into +`ESP_ID`; otherwise create the intended ESP as shown below. Do not select by +name when multiple records share that name. + +If creating one, capture its public ID from the response: + +```sh +ESP_RESPONSE="$(curl --silent --show-error --fail-with-body --request POST \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" \ + -H 'Content-Type: application/json' \ + --data '{ + "name": "Shared delivery", + "provider": "smtp", + "host": "smtp.example.net", + "port": 587, + "secure": true, + "username": "smtp-user", + "password": "", + "fromName": "Example", + "fromEmail": "mail@example.com" + }')" +ESP_ID="$(printf '%s' "$ESP_RESPONSE" | jq -er '.espId')" +printf 'Using ESP %s\n' "$ESP_ID" +``` + +The response includes `espId` and a lifecycle `status` of `draft`; it never +returns the stored provider secret. Save `espId` for subsequent calls. + +Example response: + +```json +{ + "espId": "esp_01J...", + "name": "Shared delivery", + "provider": "smtp", + "host": "smtp.example.net", + "port": 587, + "secure": true, + "username": "smtp-user", + "hasPassword": true, + "fromName": "Example", + "fromEmail": "mail@example.com", + "status": "draft", + "secretVersion": 1, + "lastTestedAt": null, + "lastTestStatus": null, + "lastTestError": null, + "activatedAt": null, + "drainUntil": null, + "retiredAt": null, + "updatedAt": "2026-09-01T10:02:00.000Z" +} +``` + +Send a test message with an explicit recipient. Organization-key calls have +no signed-in user's email to use as a fallback: + +```sh +curl --fail-with-body --request POST \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps/$ESP_ID/test" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" \ + -H 'Content-Type: application/json' \ + --data "{\"to\":\"$TEST_RECIPIENT_EMAIL\"}" +``` + +Successful test response: `{"success":true}`. + +After a successful test, activate the ESP: + +```sh +curl --fail-with-body --request POST \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/esps/$ESP_ID/activate" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" +``` + +The activation response is the ESP projection with `status: "active"` and +its public `espId`. + +Set it as the default for new teams and enable automatic grants: + +```sh +curl --fail-with-body --request PUT \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/delivery-policy" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" \ + -H 'Content-Type: application/json' \ + --data "{\"defaultEspId\":\"$ESP_ID\",\"autoGrantDefaultEsp\":true}" +``` + +The response includes `defaultEspId`, `autoGrantDefaultEsp`, the nullable +quota fields, team-delivery defaults, and `updatedAt`, for example: + +```json +{ + "defaultEspId": "esp_01J...", + "autoGrantDefaultEsp": true, + "defaultDailyLimit": null, + "defaultMonthlyLimit": null, + "aggregateDailyLimit": null, + "aggregateMonthlyLimit": null, + "teamEspEnabledByDefault": true, + "teamCanChangeDefault": true, + "updatedAt": "2026-09-01T10:05:00.000Z" +} +``` + +The request is validated against the active ESPs owned by that organization. +Owner/admin dashboard sessions continue to work. Organization keys need +`delivery:read` for policy `GET` and `delivery:manage` for policy `PUT`; +`esps:manage` and `grants:manage` do not imply either permission. + +Read the current policy before writing it: + +```sh +curl --fail-with-body \ + "$SENDLIT_API_URL/organizations/$ORGANIZATION_ID/delivery-policy" \ + -H "Authorization: Bearer $BOOTSTRAP_DELIVERY_SETUP_API_KEY" +``` + +If a policy is already configured, do not overwrite an operator's later +choices on every application restart. Run setup as an explicit one-shot job +or record a completion marker in the consuming integration; reruns should +confirm the intended ESP ID and skip the write when setup is complete. + +After this setup succeeds, revoke the delivery setup key if no further +delivery-policy automation is required. Clear its `BOOTSTRAP_DELIVERY_SETUP_API_KEY` +value from the SendLit Compose environment before the next bootstrap run. + +## Provision teams with the runtime key + +CourseLit should use only the team-provisioning key at runtime. It does not +need `esps:manage`, `grants:manage`, or delivery-policy authority. Provision +one team per stable external tenant identifier: + +```sh +curl --fail-with-body --request POST \ + "$SENDLIT_API_URL/provisioning/teams" \ + -H "Authorization: Bearer $BOOTSTRAP_TEAM_PROVISIONING_API_KEY" \ + -H 'Content-Type: application/json' \ + --data '{ + "externalId": "school:school-123", + "name": "School 123", + "sender": { "fromName": "School 123" }, + "mailingAddress": "123 Example Street, Example City", + "delivery": { "useOrganizationDefault": true } + }' +``` + +The first response has `created: true` and returns a team API key in `apiKey`; +persist it securely if the integration sends through team-scoped APIs. An +identical retry uses the same `(organization, externalId)` and returns +`created: false` with `apiKey: null`. With automatic grants enabled, a new +team receives the active organization ESP grant and uses it as its default. +The response has this shape on first creation (store the returned team key +immediately): + +```json +{ + "teamId": "team_01J...", + "externalId": "school:school-123", + "name": "School 123", + "deliverySource": { "type": "organization" }, + "created": true, + "apiKey": "sl_live_..." +} +``` + +An identical replay has `"created": false` and `"apiKey": null`. + +See [Provisioning teams](/developers/provisioning) for team lifecycle and +retry behavior. + +## Rotation and migration + +To rotate a configured key, create a new random key, update its `.env` value, +restart the one-shot bootstrap, switch the consuming integration, verify the +new key, and explicitly revoke the old key. Bootstrap does not automatically +revoke older keys. + +For deployments using the old key printed in `init` logs, keep that key active +while registering and verifying `BOOTSTRAP_TEAM_PROVISIONING_API_KEY`. +Switch CourseLit to the new key, then revoke the old broad key from the +dashboard or authenticated API. Do not rely on old logs as the recovery path. + +## REST, SDK, and agent integrations + +Use `/openapi.json` as the machine-readable source for paths and schemas, or +`/docs` to inspect it interactively. SDK/agent clients should send the +organization key as an HTTP Bearer credential and call REST for organization +bootstrap and delivery policy. Organization keys do not authenticate to MCP; +MCP remains team-scoped (team key or user OAuth). + +## Scope boundary + +This guide covers the SendLit API and bootstrap contract. A separate consumer +Compose setup owns local transport configuration, such as a Mailpit host, +health checks, and a configurable test recipient. Those are not special +behaviors in SendLit's general REST API. diff --git a/apps/web/.env.example b/apps/web/.env.example index 9acb7a7..ec62c35 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -14,3 +14,13 @@ ITEMS_PER_PAGE=10 # Maximum size (in bytes) accepted for image uploads in the email image # picker. Defaults to 10MB if unset. MAX_UPLOAD_SIZE=10485760 + +# Optional: PostHog product analytics, identify, and session recording. +# Read at request time (not baked into `next build`) so a prebuilt OSS image +# can enable telemetry at container start. When unset, PostHog is disabled. +# Use the same project token as apps/api. Enable Session replay on the +# PostHog project to record dashboard sessions. +POSTHOG_API_KEY= +POSTHOG_HOST=https://us.i.posthog.com +# Reported as the deployment environment (falls back to NODE_ENV). +DEPLOY_ENV= diff --git a/apps/web/README.md b/apps/web/README.md index c62f2cc..b817a93 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -8,6 +8,11 @@ This web application provides a user interface for managing email automation bas - Authenticates through Better Auth. The same-origin BFF proxies `app/api/auth/*` to the API, keeps the Better Auth session cookie httpOnly, and sends dashboard REST calls through `app/api/proxy/[...path]`. +- Optional PostHog: set `POSTHOG_API_KEY` (and optionally `POSTHOG_HOST`) at + runtime. The key is read by the server and passed into the browser SDK so + it is not compiled into `next build`. When set, signed-in users are + identified and session recording runs if Session replay is enabled on the + PostHog project. Unset keeps telemetry off. - Uses `packages/email-blocks` for composing broadcasts/sequences/templates, and `packages/email-editor` (via `email-blocks`) for the WYSIWYG editor. 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/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 2871682..94e63d0 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -392,11 +392,10 @@ export default function SettingsPage() { {generalSettings && !generalSettings.mailingAddress?.trim() && ( - A mailing address is required before - this workspace can send email. Add and - save it below to enable broadcasts, - sequences, transactional email, and ESP - test sends. + Add and save a mailing address to enable + broadcasts and sequences. Transactional + email and ESP test sends do not require + one. )} @@ -408,7 +407,8 @@ export default function SettingsPage() {