commit 3aacbbe278949a5a5519871a9e725ae78fc354b3 Author: Keysat Date: Wed May 20 12:51:31 2026 -0500 Initial commit — multi-provider + SMTP plans diff --git a/keysat-smtp-emails.md b/keysat-smtp-emails.md new file mode 100644 index 0000000..b56a516 --- /dev/null +++ b/keysat-smtp-emails.md @@ -0,0 +1,452 @@ +# Keysat email integration — operator alerts + buyer transactional emails + +## Context + +Today Keysat does NOT send any email. The architecture is deliberately +delegation-only: every notable event fires a signed HMAC webhook to whatever +endpoints operators have configured (`webhook_endpoints` table), and the +operator's app decides whether to render that as an email, push, in-app +banner, Slack DM, etc. The renewal-worker comment in `subscriptions.rs` +makes this explicit: *"Keysat does not email buyers itself — operator-driven +communication, same as license issuance."* + +Two cases push against that stance: + +1. **Operator-facing operational alerts.** When something goes wrong INSIDE + Keysat (Zaprite auth dies, webhook deliveries to the operator's own + endpoint fail 25 times in a row, the master Keysat license is about to + expire) — the operator needs to know NOW. Webhooks are the wrong channel + because the operator's webhook receiver is itself part of what might be + broken, and the operator doesn't have a separate "Keysat health monitor" + service waiting to act on these. Email straight from Keysat to the + operator, via StartOS SMTP, is the right channel. + +2. **Buyer-facing emails when Keysat IS the brand.** The master Keysat + instance (running at keysat.xyz, selling Keysat licenses themselves) is + the one operator that legitimately wants Keysat-branded buyer emails — + the buyer is buying Keysat, so the email should come from "Keysat + support." Most operators (selling Recaps, etc.) should NOT have Keysat + send buyer emails — their buyer relationship belongs to their own brand + and their existing email pipeline. So this needs to be opt-in per + **merchant profile**, with sensible defaults (off everywhere except the + master Keysat profile). + +The plan implements both, with clean separation between the two audiences +so future operators can opt into one without the other. + +## Dependency on the merchant-profile model + +This plan composes with `multi-provider-payment-model.md` (the +merchant-profile foundation). Per-merchant-profile SMTP override fields +already land in that plan's `merchant_profiles` table (so a Pro/Patron +operator running 3 businesses can send each one's buyer emails from a +different domain with proper SPF/DKIM/DMARC). The per-business email +branding fields (`sender_name`, `reply_to`, `support_url`, `brand_color`) +also live on merchant profiles, not products. + +**This plan's scope is therefore:** +- The `notifications` module + StartOS SMTP wiring + the `Send test + email` UI (Phase 1 below). +- The operator-alerts module + Tier-1 alert call sites (Phase 2). +- The buyer-email infrastructure + templates + per-profile opt-in flag + + per-event-type opt-outs (Phase 3). +- Flipping the master Keysat profile's flag to ON (Phase 4). + +**It assumes the merchant-profile migration has already shipped.** If +that order flips (we want to ship some email work before +merchant-profiles), the schema in this plan would need to be temporarily +on `products` instead, with a follow-up migration to move to +`merchant_profiles` later. That's mechanical but ugly; recommend +shipping merchant-profiles first. + +## High-level shape + +- **New `notifications` module** (`src/notifications/mod.rs`) — provider- + agnostic interface (`send_email(to, subject, body)`), implemented against + StartOS SMTP. Future-extensible to other channels (Slack, Discord, etc.) + if any operator ever asks. +- **New `operator_alerts` module** — fires `notifications::send_email` to + the operator's address whenever a tracked system-level condition trips. + Always on; no opt-in. Recipient = operator's email from StartOS or a + Keysat setting. +- **Extension of the existing webhook-dispatch path** to ALSO send a buyer + email when a per-merchant-profile `keysat_sends_buyer_emails = true` + flag is set AND the event type is in the buyer-relevant set. Default + off everywhere except the master Keysat profile (Pro/Patron tier + required to flip it on for non-master profiles). +- **Template system** — markdown/HTML templates per event type, with + per-profile override fields already on `merchant_profiles` (sender + name, reply-to, brand color, support URL, custom intro paragraph) so + even Keysat-direct emails are sent from the right business with the + right branding for each profile. Default templates use the merchant + profile's `name` field. + +## StartOS SMTP integration + +StartOS exposes operator-configured SMTP credentials at the OS level so +services don't each have to re-prompt for them. Verify the exact shape +before coding (read StartOS 0.4.x SDK docs + look at how an existing +StartOS package consumes SMTP — `hello-world-startos` or `nostr-rs-relay` +likely have examples). + +Expected shape based on StartOS 0.4 conventions: + +- StartOS exposes SMTP host + port + username + password + from-address + via either an env var injected into the package's container OR a + read-only API call to the host (`start-cli system smtp get` or similar). +- The `start-sdk`'s TypeScript layer (in our `startos/main.ts`) is the + right place to read and pass them through — either as env vars set on + daemon startup or as a generated config file mounted into `/data/`. +- Keysat daemon reads them at boot, holds them in `AppState::config.smtp`, + every `send_email()` call uses them. + +**Behavior when SMTP isn't configured** on the StartOS host: log a WARN +("operator alert dropped: no SMTP configured at StartOS layer; configure +in StartOS Settings → Email"), no-op the send, do NOT block the operation +that triggered the alert. Keysat's primary job is licensing; email is a +notification side-channel. + +**Behavior when SMTP send fails** (auth error, host unreachable): log +ERROR with the underlying SMTP error, optionally queue for retry with a +small bounded retry budget (3 attempts, exponential backoff). Don't +implement a full mailqueue for v1 — failed alerts that retry too many +times silently drop, the daemon log is the source of truth. Add a real +mailqueue when an operator reports lost alerts. + +## Operator alerts catalog (always-on, recipient = operator) + +Each one is a small `tracing`-style call site that emits an `OperatorAlert` +event, the `operator_alerts` module routes to email + audit log. Listed by +priority of "operator would want to know within hours." + +### Tier 1 — operator action required, send immediately + +- `payment_provider.auth_failed` — Zaprite or BTCPay API call returns 401 + on N consecutive attempts. Means the API key was rotated / revoked. + Body includes which provider, when last-good was, suggested fix + ("re-run Connect "). +- `subscriptions.auto_charge_consecutively_failing` — N (default 3) + consecutive renewals failed across the active subscription pool, OR + M% (default 25%) of recent renewals failed. Pattern detector, not + per-sub. Body includes sample failure reasons. +- `webhook_endpoint.failing` — a single configured webhook endpoint has + hit dead-letter status (failed `MAX_ATTEMPTS` deliveries). Body includes + the endpoint URL, recent error responses, suggested fix. +- `master_license.expiring_soon` — master Keysat license expires in N + days (default 14). Body includes the upgrade URL. +- `master_license.expired` — master Keysat license has expired. Body + flags which features are now locked (per tier-cap matrix), upgrade URL. + +### Tier 2 — operator should know within a day + +- `disk_space.low` — Keysat-specific (DB growth). StartOS likely also + alerts on overall disk, but Keysat can warn earlier if the DB grows + unexpectedly. Skip until operator-reported. +- `db.backup_overdue` — if Keysat tracks last-backup timestamps. Defer. +- `update_available` — new Keysat version published to registry, opt-in. + Useful but not v1. + +### Tier 3 — informational digest + +- `weekly_summary` — N licenses issued, N revoked, N renewals settled, + $X gross revenue this week. Optional, opt-in. Defer. + +**Throttling.** Each alert has a per-alert-kind cooldown (default 6 hours) +so a flapping provider doesn't email the operator every minute. Cooldown +state persists in a `operator_alert_throttle (kind, last_sent_at)` table. + +## Buyer transactional emails (opt-in per merchant profile) + +When `merchant_profiles.keysat_sends_buyer_emails = 1` for the profile +the event's product belongs to, ALSO send a buyer email (in addition to +dispatching the webhook). Default `0` everywhere except the master +Keysat profile, which defaults to `1` because that's where Keysat IS +the brand. Flipping the flag to `1` on a non-default profile is +tier-gated to Pro/Patron (Creator-tier operators only have one profile, +so the question only matters for them in the master-keysat case; the +gate exists for symmetry with the merchant-profile count cap). + +### Event-to-email mapping + +| Webhook event | Buyer email | +|---|---| +| `license.issued` (one-shot purchase OR first-cycle recurring settle) | **Purchase confirmation + license key delivery** — receipt, the actual key, download/install instructions link. Critical for buyers who closed the tab before saving the key. | +| `subscription.renewal_pending` | **Renewal reminder** — your next charge is X on Y, link to update card / cancel, link to current subscription state. | +| `subscription.auto_charge_initiated` | (Optional, off by default) **Auto-charge receipt** — "we charged $X to your saved card, here's the receipt." Some operators want this; most don't (transaction email fatigue). | +| `subscription.auto_charge_failed` | **Payment failed — action needed** — your card declined, here's the manual-pay link, your subscription is in a grace period until Y. Most-important buyer email since it's the only path to recover the cycle. | +| `subscription.renewed` | **Renewal receipt** (after settle confirmed). Same content as auto-charge initiated but only sent after confirmation. Pick ONE of this or auto-charge-initiated to avoid double-emails — default to this one. | +| `subscription.lapsed` | **Subscription ended** — grace period expired, license no longer active, link to re-subscribe. | +| `code.redeemed` | (Optional, off by default) Discount-code-tied notification, mostly internal. | + +### Template system + +- One default template per event type, shipped with the binary in + `src/notifications/templates/.txt` (markdown source rendered to + HTML + plain-text alternatives). +- Per-merchant-profile branding overrides already exist on the + `merchant_profiles` table from the multi-provider plan (`sender_name`, + `reply_to`, `support_url`, `brand_color`, `support_email`, plus the + optional SMTP-override block). This plan adds the templating-specific + fields (`intro_paragraph_md`, `footer_md`, `include_license_key`, + `disabled_events`) as additional columns on `merchant_profiles`. +- Default `sender_name` = profile's `name`. Default `reply_to` = + profile's `support_email` (or operator-level fallback if NULL). + Default `support_url` = profile's `support_url`. So even with zero + customization, emails come from "Recaps " not + "Keysat " — the profile's identity is the source of + truth. +- Templates use a small {{handlebars-style}} variable system. Variables + available: `buyer_email`, `license_id` (last 8 chars for display), + `policy_slug`, `product_name`, `merchant_profile_name`, `listed_price` + + `currency`, `next_renewal_at`, custom fields from the event + payload. +- License-key-delivery template is special: includes the FULL license + key (verifiable offline against the operator's pubkey). Operators + selling high-value products may want to disable this (deliver + out-of-band). Controlled by `merchant_profiles.include_license_key` + (default 1). + +### Anti-double-email guard + +When BOTH a webhook AND a buyer-email fire for the same event, the +operator's webhook receiver might ALSO be sending its own email — double +inbox spam for the buyer. Mitigations: + +- Admin UI surfaces a clear warning when the operator enables both + `keysat_sends_buyer_emails` AND has an active webhook endpoint + subscribed to overlapping events: "Recaps's webhook receiver is also + subscribed to subscription.renewal_pending. If it's already sending + emails, enabling Keysat-direct emails will duplicate buyer mail." +- Don't try to programmatically prevent — that's brittle and Keysat + can't know what the operator's webhook code does. Warn + let them + decide. + +## Schema changes + +New migration `0021_email_notifications.sql` (ships AFTER +`0020_merchant_profiles.sql` from the multi-provider plan): + +```sql +PRAGMA foreign_keys = ON; + +-- Operator's email (the human running the box). Used for operator +-- alerts. Auto-populated from StartOS account on first boot when +-- available; editable in admin UI Settings page. +INSERT OR IGNORE INTO settings(key, value) + VALUES ('operator_email', NULL); + +-- Per-merchant-profile email flags + customization. Most identity- +-- and-branding fields (sender_name, reply_to, support_url, brand_color, +-- support_email, optional SMTP override block) are already on +-- merchant_profiles from migration 0020. This migration adds the +-- email-specific behavior columns. +ALTER TABLE merchant_profiles + ADD COLUMN keysat_sends_buyer_emails INTEGER NOT NULL DEFAULT 0 + CHECK (keysat_sends_buyer_emails IN (0, 1)); +ALTER TABLE merchant_profiles + ADD COLUMN intro_paragraph_md TEXT; -- prepended to every email body +ALTER TABLE merchant_profiles + ADD COLUMN footer_md TEXT; -- replaces default footer +ALTER TABLE merchant_profiles + ADD COLUMN include_license_key INTEGER NOT NULL DEFAULT 1 + CHECK (include_license_key IN (0, 1)); +ALTER TABLE merchant_profiles + ADD COLUMN disabled_events TEXT; -- JSON array of event_type strings + +-- On the master Keysat instance, flip the default profile's flag to ON +-- (it's the one place buyers are buying Keysat-the-product and the +-- email comes from Keysat). On every other operator's instance, the +-- default profile starts with the flag OFF — they keep using their own +-- email pipeline via webhooks. Operators can flip it ON later +-- (tier-gated for non-default profiles). +-- +-- This UPDATE is conditional: only flip if the operator_name resolves +-- to "Keysat" (i.e., this is the master instance). Other operators +-- will see their default profile stay at 0. +UPDATE merchant_profiles + SET keysat_sends_buyer_emails = 1 + WHERE is_default = 1 + AND name = 'Keysat'; + +-- Throttle state for operator alerts (one cooldown per alert kind). +CREATE TABLE operator_alert_throttle ( + kind TEXT PRIMARY KEY, + last_sent_at TEXT NOT NULL, + last_payload TEXT -- JSON, last alert body for dedup +); + +-- Audit trail of emails sent. Operator-debuggable; bounded by a TTL +-- sweep so it doesn't grow forever. +CREATE TABLE email_log ( + id TEXT PRIMARY KEY, + sent_at TEXT NOT NULL, + to_address TEXT NOT NULL, + subject TEXT NOT NULL, + audience TEXT NOT NULL, -- 'operator' | 'buyer' + merchant_profile_id TEXT, -- which profile's branding/SMTP was used; NULL for operator alerts + event_type TEXT, -- nullable; some operator alerts not event-driven + product_id TEXT, -- nullable for operator alerts + invoice_id TEXT, -- nullable + subscription_id TEXT, -- nullable + license_id TEXT, -- nullable + status TEXT NOT NULL, -- 'sent' | 'failed' | 'dropped_no_smtp' + error_message TEXT, + CHECK (audience IN ('operator', 'buyer')), + CHECK (status IN ('sent', 'failed', 'dropped_no_smtp')) +); +CREATE INDEX idx_email_log_sent_at ON email_log(sent_at); +CREATE INDEX idx_email_log_audience ON email_log(audience); +``` + +## Rust code changes + +- New `src/notifications/mod.rs`: + - `pub struct NotificationsService { default_smtp: SmtpConfig, db: SqlitePool }` + - `pub async fn send_email(&self, profile: Option<&MerchantProfile>, to: &str, subject: &str, body_text: &str, body_html: Option<&str>) -> Result<()>` + — when `profile` is `Some`, use the profile's SMTP override fields if present; otherwise fall back to the StartOS-level SMTP. Operator alerts pass `None` (always uses the default SMTP since they don't belong to any business). + - Logs to `email_log` table on every attempt regardless of outcome, + tagging which profile's config was used. + - Implementation: use `lettre` crate (well-maintained Rust SMTP + client) with TLS/STARTTLS support. +- New `src/notifications/templates.rs`: + - Template registry mapping event_type → default template. + - Render function: takes template + variables + per-profile overrides + → returns subject, text body, HTML body. + - Templates embedded via `include_str!` so no filesystem dependency + in container. +- New `src/operator_alerts.rs`: + - `pub fn alert(state: &AppState, kind: OperatorAlertKind, payload: serde_json::Value)` + - Reads throttle state, sends email (via default SMTP, no profile) + if cooldown elapsed, persists last_sent_at. +- New call sites in existing modules: + - `subscriptions.rs` — `auto_charge_consecutively_failing` detector + (windowed counter) + - `payment/zaprite/client.rs` + `payment/btcpay/client.rs` — + `payment_provider.auth_failed` on N consecutive 401s + - `webhooks.rs` (delivery worker) — `webhook_endpoint.failing` when + an endpoint hits dead-letter + - `tier.rs` — `master_license.expiring_soon` / + `master_license.expired` daily check +- Extend `webhooks::dispatch` to also call `notifications` when the + product's `merchant_profile.keysat_sends_buyer_emails = 1` AND the + event is buyer-relevant. The notification call passes the profile, + so per-profile SMTP / branding applies. +- `AppState::config` gains an `smtp: Option` field + (default StartOS-level SMTP), populated at startup. +- `startos/main.ts` (TypeScript wrapper): read StartOS SMTP config and + pass it to the daemon as env vars or a generated config file. + +## Admin UI changes (in `web/index.html`) + +- **Settings page** → new "Notifications" disclosure (operator-level): + - Operator email field (auto-populated from StartOS, editable) + - Default SMTP status (connected/error/not configured), inherited from + StartOS + - "Send test email to operator" button to verify the default SMTP path + - List of recent emails sent (paginated, from `email_log`), filterable + by audience (operator vs buyer) and by merchant profile +- **Merchant Profile edit page** (already exists from multi-provider + plan) → "Buyer emails" disclosure: + - Toggle: "Have Keysat send buyer emails for this merchant profile" + (default off; default on for the master Keysat profile only) + - Per-event opt-outs (checkboxes for each buyer-event row in the + Event-to-email mapping above) + - "Intro paragraph" + "Footer" markdown fields + - "Include license key in confirmation email" checkbox + - "Test send" button rendering one of the templates against the + profile's SMTP config + branding + - Inline warning if any active webhook is subscribed to overlapping + events (anti-double-email guard from above) +- (Branding + SMTP-override fields live on the existing Merchant + Profile edit page sections, not duplicated here — they're owned by + the multi-provider plan.) + +## Sequencing + +Single cycle if it's all you ship. Otherwise consider this order: + +1. **Phase 1 — StartOS SMTP wiring + notifications module** (`:NN+1`). No + call sites yet, just the plumbing + a `Send test email` button on the + Settings page so operators can validate the path. Low risk, immediately + testable. +2. **Phase 2 — Operator alerts** (`:NN+2`). Wire in the 4-5 Tier-1 alerts. + No buyer-facing changes. Operators get immediate value. +3. **Phase 3 — Buyer emails infrastructure** (`:NN+3`). Schema + + template engine + admin UI for the per-merchant-profile config. + Default everything off — no behavior change yet. +4. **Phase 4 — Buyer email triggers** (`:NN+4`). Wire the webhook- + dispatch path to also send emails when configured. Default ON for the + master Keysat profile, OFF everywhere else. This is the user-visible + cut. + +Phases can be one release each (4 routine bumps) or rolled together if +they're shipped as a coherent feature. + +## Verification + +1. **Test send from Settings page** — verifies StartOS SMTP integration + end-to-end without needing a real event to trigger. +2. **Operator alert test**: revoke the Zaprite API key on Zaprite's + dashboard, trigger any purchase attempt → expect a Tier-1 email within + the throttle window naming the provider and suggesting the fix. +3. **Buyer email test (master Keysat)**: buy a Keysat self-license on + the master keysat.xyz instance (its profile has the flag = 1 from + the migration) → expect a Keysat-branded confirmation email with the + license key, sent from the Keysat profile's SMTP config + branding. +4. **Buyer email test (Recaps operator path)**: on an operator instance + with a Recaps merchant profile (flag = 0), buy a Recaps license → + confirm Keysat sends NO buyer email; only the webhook fires for + Recaps's receiver to handle. +5. **Per-profile SMTP test (Pro/Patron)**: on a Pro-tier operator + instance, create two profiles, each with its own SMTP override + (different sender domains). Buy a product on each. Confirm each + buyer email arrives from the correct sender domain with the correct + profile branding (verifies the per-profile SMTP routing). +6. **Anti-double-email warning**: enable + `merchant_profiles.keysat_sends_buyer_emails` on a profile that has + a webhook subscribed to overlapping events → confirm the admin UI + shows the warning. +7. **Throttle test**: trigger the same operator alert twice in quick + succession → confirm only one email sent, second one suppressed, + audit log shows the cooldown. + +## Out of scope for this work — future considerations + +- **Other notification channels** (Slack, Discord, push). Email is the + highest-leverage channel and matches StartOS's existing infrastructure. + Other channels are a natural extension if any operator asks. +- **Operator-side email-domain authentication setup** (DKIM/SPF + configuration help). Keysat consumes whatever StartOS gives it; + operators handle their own deliverability hygiene. We can link to a + docs page from the Settings UI. +- **HTML email template editor** in the admin UI. Operators get + markdown + a small variable set; if they want full template control, + they use webhooks + their own pipeline. Adding a WYSIWYG editor is + a much bigger UI investment. +- **Per-customer email preferences** (let buyers opt out of renewal + reminders). Not a v1 feature — buyers can ignore emails. Build when + someone hits GDPR / CAN-SPAM compliance edge cases at scale. +- **Multi-language email templates**. Defer; templates ship English-only. +- **Transactional email service integrations** (SendGrid/Postmark/etc. + in addition to or instead of StartOS SMTP). StartOS SMTP is the + right default — it's already configured and the operator pays nothing + extra. Other providers are a deferred option. + +## How this composes with the multi-provider / merchant-profile plan + +The merchant-profile plan ships first (`migration 0020`) and creates the +`merchant_profiles` table along with the identity, branding, and +optional-SMTP-override fields. This plan layers on top (`migration +0021`) adding the email-behavior columns (`keysat_sends_buyer_emails`, +`intro_paragraph_md`, `footer_md`, `include_license_key`, +`disabled_events`) plus the operator-level `operator_email` setting, the +`operator_alert_throttle` table, and the `email_log` table. + +Admin UI work is symmetric: the multi-provider plan owns the Merchant +Profile edit page's identity, branding, and providers sections. This +plan adds a "Buyer emails" disclosure on the same page. + +No code conflict — the merchant-profile plan touches payment routing, +this plan touches the dispatch tail. They compose cleanly. Shipping +them in sequence (rather than as one combined cut) keeps each cycle's +scope reviewable. diff --git a/multi-provider-payment-model.md b/multi-provider-payment-model.md new file mode 100644 index 0000000..3f9f6a3 --- /dev/null +++ b/multi-provider-payment-model.md @@ -0,0 +1,585 @@ +# Keysat multi-merchant-profile + multi-provider model + +## Context + +Today both `btcpay_config` and `zaprite_config` are singleton rows +(`id INTEGER PRIMARY KEY CHECK (id = 1)`), and `SETTING_ACTIVE_PROVIDER` +picks one of the two as the daemon's process-wide active provider. Every +call to `state.payment_provider()` returns that one provider, regardless of +which product is being sold. There's no concept of a "merchant" or "seller" +identity anywhere — every product on a Keysat instance is implicitly being +sold by the same legal entity, through the same payment account, with the +same branding and the same post-purchase landing page. + +That model breaks the moment a software author wants to run ONE Keysat +instance for multiple distinct businesses. Concrete example: one operator +sells **Recaps** licenses (settled to a Recaps Zaprite org, branded as +Recaps, buyers redirected to `recaps.cc`) AND **Keysat** licenses (settled +to a Keysat Zaprite org, branded as Keysat, buyers redirected to +`keysat.xyz`). Today's architecture forces both to share one merchant +identity, one provider account, one branding set, one redirect URL. + +This plan introduces a **merchant profile** layer that owns business +identity, branding, redirect, optional SMTP, AND a set of payment +providers. Products attach to a merchant profile (not directly to a +provider). The buyer sees the merchant profile's brand at checkout and +picks a payment rail from the providers attached to that profile. + +Tier-gating: Creator (free) gets 1 merchant profile. Pro/Patron get N. + +## Why this shape, not the simpler "per-product provider override" + +An earlier draft of this plan had products carry a nullable +`payment_provider_id` override directly. That worked but conflated two +concerns: **business identity** (who's selling this? what's the brand? +where do buyers land?) and **payment routing** (which Stripe / Bitcoin +account receives the money?). In practice an operator running two +businesses wants every product of business A to share a brand AND a set of +payment accounts AND a redirect; copying those fields onto every product +of that business would be redundant and error-prone. + +Merchant profile cleanly separates the two: +- **Profile** = the business identity (brand, redirect, support contact, + optional SMTP), and the set of payment providers that legally settle TO + that business. +- **Provider** = the technical credential to a specific payment account + (BTCPay store + API key, OR Zaprite org + API key). One provider per + account; a profile can have many providers (e.g. BTCPay for Bitcoin + AND Zaprite for card). +- **Product** = what's being sold, attached to one profile. + +This also makes **buyer-currency routing** (previously a deferred future- +consideration) fall out for free: the buy page shows which payment rails +the product's profile supports, the buyer picks Bitcoin / Lightning / +Card, we route through the right provider. + +## Design overview + +### Data model + +``` +merchant_profiles (1) ──────< (N) payment_providers + (1) ──────< (N) products + (1) ──────< (N) subscriptions [snapshot on create] +``` + +- Exactly one `merchant_profiles.is_default = 1` row. Auto-created on + first boot after upgrade, populated from existing + `SETTING_OPERATOR_NAME`. New operators get one default profile too. +- Each `payment_provider` belongs to exactly ONE profile + (`merchant_profile_id NOT NULL`). Providers move with the business. +- Each product has `merchant_profile_id NOT NULL`. Default to the + default profile. +- Each subscription snapshots BOTH `merchant_profile_id` and + `payment_provider_id` at creation, so mid-cycle changes to the product + don't silently redirect existing subs to a different business or rail. + +### Buy-page resolution at runtime + +1. Buyer hits `/buy/`. +2. Server loads product → product's merchant profile → profile's attached + providers. +3. Buy page renders the merchant profile's brand (name, color, support + link), the product's price/tiers, and a payment-method picker exposing + every rail the attached providers offer (Lightning, on-chain, card, + etc.). +4. Buyer picks a rail. The picker resolves to one provider (e.g., "Card" + → the Zaprite provider attached to this profile). +5. Server calls `state.payment_provider_for(profile_id, rail)` → + `create_invoice` on that provider. +6. After settle, redirect goes to profile's + `post_purchase_redirect_url` if set, else Keysat's default + `/thank-you?invoice_id=…` page. + +### Subscription renewal resolution + +Renewal worker reads `sub.payment_provider_id` and +`sub.merchant_profile_id` from the subscription snapshot — never re- +resolves from the product. This protects existing buyers from operator +edits. + +## Tier gating + +- **Creator (free)** — exactly 1 merchant profile. Auto-created on first + boot; can be edited but not deleted, and the create-profile endpoint + refuses with 402 + upgrade URL if a Creator already has one. +- **Pro / Patron** — unlimited merchant profiles. Same cap-resolution + pattern as the existing `unlimited_products` / `unlimited_policies` + entitlements (`tier::current()` returns the cap; admin endpoint checks + before insert; the existing tier-cap modal shows the upgrade CTA). + +New entitlement string: `unlimited_merchant_profiles`. Master Keysat's +Pro and Patron policies need this added to their entitlement lists; the +master operator's self-license then signs with it baked in. + +## Schema (migration 0020) + +```sql +PRAGMA foreign_keys = ON; + +CREATE TABLE merchant_profiles ( + id TEXT PRIMARY KEY, -- UUID v4 + name TEXT NOT NULL, -- "Recaps", "Keysat" + legal_name TEXT, -- optional, for receipts/tax + support_url TEXT, + support_email TEXT, + brand_color TEXT, -- hex + post_purchase_redirect_url TEXT, -- NULL = Keysat default /thank-you + is_default INTEGER NOT NULL DEFAULT 0, + -- Optional per-profile SMTP override. NULL means inherit the + -- StartOS-level / Keysat-singleton SMTP config. Lets a Pro/Patron + -- operator running 3 businesses send emails from 3 different + -- domains/senders WITHOUT having to configure 3 separate StartOS + -- accounts. Pairs with the keysat-smtp-emails plan. + smtp_host TEXT, + smtp_port INTEGER, + smtp_username TEXT, + smtp_password TEXT, -- encrypted at rest (TBD) + smtp_from_address TEXT, + smtp_from_name TEXT, + smtp_use_starttls INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (is_default IN (0, 1)), + CHECK (smtp_use_starttls IN (0, 1)) +); +CREATE UNIQUE INDEX idx_merchant_profiles_one_default + ON merchant_profiles(is_default) WHERE is_default = 1; + +CREATE TABLE payment_providers ( + id TEXT PRIMARY KEY, -- UUID v4 + merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id), + kind TEXT NOT NULL, -- 'btcpay' | 'zaprite' + label TEXT NOT NULL, -- "Recaps BTCPay", operator-set + api_key TEXT NOT NULL, + base_url TEXT NOT NULL, + webhook_id TEXT, + webhook_secret TEXT, -- BTCPay HMAC; NULL for Zaprite + store_id TEXT, -- BTCPay only + connected_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (kind IN ('btcpay', 'zaprite')) +); +CREATE INDEX idx_payment_providers_profile ON payment_providers(merchant_profile_id); +-- Within a profile, at most one provider of each kind (avoid the +-- two-BTCPay-providers-same-business confusion): +CREATE UNIQUE INDEX idx_payment_providers_profile_kind + ON payment_providers(merchant_profile_id, kind); + +ALTER TABLE products + ADD COLUMN merchant_profile_id TEXT + REFERENCES merchant_profiles(id); +CREATE INDEX idx_products_profile ON products(merchant_profile_id); + +ALTER TABLE subscriptions + ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id), + ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id); +CREATE INDEX idx_subs_profile ON subscriptions(merchant_profile_id); +CREATE INDEX idx_subs_provider ON subscriptions(payment_provider_id); +``` + +### Migration body (data port) + +```sql +-- 1. Create the default merchant profile from existing operator settings. +INSERT INTO merchant_profiles( + id, name, support_url, support_email, brand_color, + post_purchase_redirect_url, is_default, created_at, updated_at +) +SELECT lower(hex(randomblob(16))), + COALESCE((SELECT value FROM settings WHERE key='operator_name'), 'Keysat'), + NULL, NULL, NULL, NULL, + 1, datetime('now'), datetime('now'); + +-- 2. Migrate btcpay_config → payment_providers (if present), attached to default. +INSERT INTO payment_providers( + id, merchant_profile_id, kind, label, + api_key, base_url, webhook_id, webhook_secret, store_id, + connected_at, updated_at +) +SELECT lower(hex(randomblob(16))), + (SELECT id FROM merchant_profiles WHERE is_default = 1), + 'btcpay', 'BTCPay (migrated)', + api_key, base_url, webhook_id, webhook_secret, store_id, + connected_at, connected_at +FROM btcpay_config; + +-- 3. Same for zaprite_config. +INSERT INTO payment_providers( + id, merchant_profile_id, kind, label, + api_key, base_url, webhook_id, webhook_secret, store_id, + connected_at, updated_at +) +SELECT lower(hex(randomblob(16))), + (SELECT id FROM merchant_profiles WHERE is_default = 1), + 'zaprite', 'Zaprite (migrated)', + api_key, base_url, webhook_id, NULL, NULL, + connected_at, connected_at +FROM zaprite_config; + +-- 4. Drop the old singleton tables. +DROP TABLE btcpay_config; +DROP TABLE zaprite_config; +DELETE FROM settings WHERE key = 'active_payment_provider'; + +-- 5. Backfill products + subscriptions to point at the migrated default. +UPDATE products + SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1); +UPDATE subscriptions + SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1), + payment_provider_id = ( + SELECT id FROM payment_providers + WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1) + AND kind = ( + -- Resolve the original provider for back-compat. Use whichever + -- provider the active-provider setting pointed at; fall back to + -- BTCPay if both exist (preserves existing single-provider + -- operator behavior). + CASE + WHEN EXISTS (SELECT 1 FROM payment_providers + WHERE merchant_profile_id = + (SELECT id FROM merchant_profiles WHERE is_default = 1) + AND kind = 'btcpay') + THEN 'btcpay' + ELSE 'zaprite' + END + ) + ); +``` + +Additive-then-cutover; not reversible without a backup restore. Release +notes call this out as a one-way migration (consistent with the v0.2.0:1+ +migration framing). Existing single-provider operators see no behavior +change — their setup becomes "1 profile + 1 provider," products and subs +all attach to that profile. + +## Rust changes + +**`src/merchant_profiles.rs`** (new): +- `pub struct MerchantProfile` mirroring a row. +- `pub async fn list(pool) -> Vec`, + `get_by_id`, `get_default`, `create`, `update`, `delete`. +- Tier gate inside `create` — refuses with `AppError::TierCap` (existing + type that triggers the 402 + upgrade modal) when Creator already has + one profile. + +**`src/payment/mod.rs`**: +- `pub struct PaymentProviderConfig` mirroring a row (now includes + `merchant_profile_id`). +- `pub fn build_provider(cfg: &PaymentProviderConfig) -> Arc` + factory dispatching on `kind`. +- `repo::list_providers_for_profile(pool, profile_id)`, + `repo::get_provider_by_id`, etc. +- Drop `SETTING_ACTIVE_PROVIDER` constant + `read/write_active_provider_preference`. +- Add a "rail" concept: each provider impl declares which payment rails + it offers (`enum Rail { Lightning, OnChain, Card, … }`). The buy page + uses this to render the payment-method picker. + +**`src/api/mod.rs` (AppState)**: +- Replace single `payment_provider` accessor with two: + - `state.merchant_profile_for(product_id) -> MerchantProfile` + - `state.payment_provider_by_id(provider_id) -> Arc` +- Add a cache `provider_cache: Cache>` + keyed by provider id; invalidated on connect/disconnect/edit. + +**Call sites that switch**: +- `src/api/purchase.rs` — resolve product → profile, then accept a + rail selection from the request body (or default to "first rail" if + product has no override), then build the provider; pass + `merchant_profile_id` AND `payment_provider_id` to subscription create. +- `src/api/upgrade.rs` — derive from license → product → profile; pick + the same rail the original sub used (snapshot). +- `src/subscriptions.rs` — use `sub.payment_provider_id` snapshot + (already planned in the prior multi-provider draft); additionally + use `sub.merchant_profile_id` to load redirect URL / branding for + the renewal webhook payload. +- `src/tipping.rs`, `src/reconcile.rs` — same pattern. + +**`src/api/webhook.rs`** — router unchanged from the prior multi-provider +draft: `/v1/{kind}/webhook/{provider_id}` is still the path. The +`provider_id` resolves to a profile transitively, and the webhook +handler doesn't need to know about profiles (it just needs to validate +the payload against the right provider's secret and update the right +invoice). + +**`src/api/btcpay_authorize.rs` and `src/api/zaprite_authorize.rs`** — +connect flows: +- Connect now takes a `merchant_profile_id` query param. Default = the + default profile if not specified. +- Refuses if a provider of the same kind already exists on that profile + (the unique index would error; we want a clean 409 with a helpful + message: "Recaps already has a Zaprite provider — disconnect it first + or connect to a different profile"). +- "Disconnect" deletes the provider row; if it was the last provider on + the profile AND that profile has active products/subscriptions, the + admin UI prompt requires picking a replacement before delete. +- New endpoint `POST /v1/admin/merchant-profiles/{id}` CRUD endpoints + for profile management. + +## Subscription snapshot semantics + +`subscriptions.merchant_profile_id` AND `subscriptions.payment_provider_id` +are both set on create and never changed by product edits. If the operator +later moves a product to a different merchant profile: + +- New purchases on that product create subscriptions tied to the NEW + profile + provider. +- Existing subscriptions keep renewing through their ORIGINAL profile + + provider. +- Trade-off: an admin "re-route this subscription" action exists as a + manual flow (see "Mid-cycle subscription migration" in future + considerations) — never automatic. + +## Admin UI changes (in `web/index.html`) + +### Settings → Merchant Profiles (new top-level page) + +- List view: table of profiles with name, default badge, attached + provider kinds (icons for BTCPay / Zaprite), product count, action + menu (edit, set default, delete). +- "Add Merchant Profile" button: tier-gated. On Creator with 1 profile, + the button shows the tier-cap upgrade modal instead. +- Profile edit page: form with all the merchant_profile fields. Section + for "Payment providers" listing attached providers with disconnect/ + reconnect; "Connect BTCPay" and "Connect Zaprite" buttons within the + profile (which is how the connect flow's `merchant_profile_id` query + param gets populated). Section for optional per-profile SMTP override + (collapsed by default; the keysat-smtp-emails plan covers the form + fields in detail). +- Delete profile: refused if the profile has any active products or + unsettled subscriptions; otherwise prompts confirm. + +### Existing Payment Providers page (rename → drop) + +- The current "Payment Providers" section on the Settings page is + removed; provider config now lives INSIDE each merchant profile's + edit page. There's no top-level "list all providers across all + profiles" view — providers are scoped to their profile. + +### Product create/edit page + +- New "Settle through" picker: + - "Merchant profile" dropdown — required, defaults to the default + profile. Lists all profiles the operator has configured. + - Below it, an info chip showing which payment rails buyers will be + offered ("Card via Zaprite + Lightning via BTCPay") based on the + chosen profile's attached providers. If the profile has no + providers, the operator sees an error: "This merchant profile has + no payment providers connected — buyers can't pay until you add + one." + +### Buy page (`/buy/`) + +- Brand block at top renders the merchant profile's `name`, optionally + with `brand_color` accent, and "Sold by {name}" subtitle. +- Existing tier-card UI unchanged except for the payment-method picker: + if the profile has 2+ providers, render a "Pay with" picker (Card / + Lightning / Bitcoin) before the final Pay button. If only 1 provider, + hide the picker (current behavior). +- After payment, redirect respects profile's + `post_purchase_redirect_url` if set; falls back to Keysat's default + thank-you page. + +## Files modified (estimated) + +- `migrations/0020_merchant_profiles.sql` (new) +- `src/merchant_profiles.rs` (new) +- `src/payment/mod.rs` — provider factory + rail concept + profile-aware + accessors +- `src/payment/btcpay.rs`, `src/payment/zaprite/{client,config,provider}.rs` + — constructors take row config; declare rails +- `src/api/{purchase,upgrade,webhook,btcpay_authorize,zaprite_authorize, + merchant_profiles}.rs` — call sites + new CRUD endpoints +- `src/api/mod.rs` (AppState) — provider cache + profile accessors +- `src/db/repo.rs` — profile + provider repo helpers +- `src/subscriptions.rs` — snapshot profile_id + provider_id +- `src/tier.rs` — wire `unlimited_merchant_profiles` entitlement +- `src/tipping.rs`, `src/reconcile.rs` — pass profile context +- `web/index.html` — new Merchant Profiles section + product picker + + buy-page profile branding + payment-method picker +- `startos/versions/v0.2.0.ts` — bump (TBD which `:NN`), release notes + flagging one-way migration + Creator tier cap + +## Verification + +1. **Unit/integration tests**: profile CRUD with tier gating + (Creator hits cap, Pro/Patron doesn't); subscription snapshot + stickiness across product moves; provider-by-rail resolution. +2. **Migration test**: copy a production-shape DB (BTCPay singleton + + one product + one active sub + active_provider setting), run + migrations, confirm: one default profile exists named per operator, + one BTCPay provider attached, product points to default profile, + sub points to both default profile + the migrated BTCPay provider. +3. **Sandbox end-to-end**: + - On a Pro-tier instance, create two profiles: "Recaps" and "Keysat". + - Connect a Zaprite sandbox org to Recaps; connect a different + Zaprite sandbox org to Keysat. + - Create a Recaps product, attach to Recaps profile. Create a Keysat + product, attach to Keysat profile. + - Buy each. Confirm: + - Recaps purchase shows "Sold by Recaps" branding on buy page, + redirects to Recaps's `post_purchase_redirect_url`, settles to + the Recaps Zaprite dashboard, webhook hits + `/v1/zaprite/webhook/{recaps-provider-id}`. + - Keysat purchase shows Keysat branding, redirects to Keysat + thank-you, settles to Keysat Zaprite dashboard. +4. **Multi-rail per profile**: + - On the Recaps profile, also connect BTCPay (separate provider). + - Buy the Recaps product again. Buy page now shows "Pay with Card" + and "Pay with Lightning" picker. + - Pick card → Zaprite. Pick Lightning → BTCPay. Confirm each settles + to the correct dashboard. +5. **Creator tier cap**: + - On a Creator-tier instance, attempt to create a second profile. + - Expect 402 + upgrade modal pointing at the master Keysat upgrade + URL. +6. **Backward-compat smoke**: + - Existing operator upgrades from `:45` to this version. Confirm + they see one profile auto-created with their existing operator + name + connected providers attached + all products + subs + linked through. + - Webhook delivered to OLD `/v1/zaprite/webhook` URL (no provider + id) still settles via the migrated default provider. + +## Sequencing + +This is a bigger change than the original multi-provider draft. Feasible +in one cycle but the admin UI surface is meaningful. Two reasonable +options: + +**Option A — single cut.** All schema + Rust + admin UI in one release. +Cleaner UX (no half-shipped intermediate states) but a heavier cut. +Estimate: 2–3 focused days. + +**Option B — two cuts.** +- Cut 1: Schema migration + Rust resolution layer + buy-page resolution. + Admin UI stays mostly unchanged (one auto-created profile, existing + payment providers page shows the default profile's providers). + Lower risk; existing operators see no UX change. +- Cut 2: Full admin UI surface (Merchant Profiles top-level page, + per-profile connect flows, profile picker on product page). Layered + on after Cut 1 is stable. + +I'd lean toward **B** — the data-model and resolution-layer changes +deserve their own focused cycle, and the UI work benefits from real +operator feedback on the new shape before we commit to the admin UX. + +## Future considerations — still on the roadmap + +The merchant-profile shape makes most of these straight extensions. +Listed roughly in order of "smallest leap from this foundation." + +### 1. Buyer-currency routing — natively supported + +**What this plan already does.** A profile with multiple providers +already exposes a multi-rail picker on the buy page. Currency +routing is the same shape: buyer picks USD → routes through the +profile's card-capable provider (Zaprite); picks BTC → routes through +Bitcoin-capable (BTCPay). No follow-up work needed beyond the +multi-rail picker. + +**What's left for a polish pass.** Currency-aware tier card pricing +(if a product is listed in USD but the buyer picks BTC, show the +sat-equivalent live). Already partly there with the multi-currency +work in v0.1.0:43+; the remaining piece is the buy-page rate +display. + +### 2. Per-policy provider — "free tier no payment, paid tiers via Stripe" + +**Value.** A product with `Free`, `Pro $5/mo` and `Patron 50000 +sats/mo` policies could route the paid policies through different +providers. Free tier has no provider at all (today it falls through +to whichever is configured + a $0 invoice). + +**What changes from this plan's foundation.** Add a nullable +`payment_provider_id` to `policies` (a SECOND override). Resolution +order: policy override → product's profile → buyer's rail pick. + +**Complexity.** Mostly UI (the policy edit modal gets a "settle +through" picker). Half a day on top of this foundation. Probably +not needed for v1 — the per-profile-multi-provider model handles +most cases. + +### 3. Mid-cycle subscription migration — "move my subs to a new merchant" + +**Value.** Operator changes payment processors (e.g., switches a +profile's Zaprite org) and wants existing recurring subs to +re-attach to the new provider/profile. + +**What changes from this plan's foundation.** New admin endpoint +`POST /v1/admin/subscriptions/{id}/migrate` that updates the sub's +`merchant_profile_id` and/or `payment_provider_id`, drops the +captured `zaprite_payment_profile_id` (which is scoped to the old +org), and either prompts the buyer to re-save their card OR issues a +one-time invoice on the new provider to capture a fresh profile on +settle. + +**Complexity.** The data model is trivial — the hard part is the +buyer-communication step. Recommended to defer until a real +operator asks. Half a day for endpoint + UI. + +### 4. Auto-failover within a merchant profile + +**Value.** A profile has BTCPay primary + Zaprite warm-standby. If +BTCPay's webhook deliveries start failing or its API is +unreachable, Keysat automatically routes the next purchase through +Zaprite without manual intervention. + +**What changes.** New `payment_providers.fallback_provider_id` +(nullable FK to another provider on the SAME profile). Purchase +flow tries the primary; on `create_invoice` failure (network, 5xx, +timeout), retries with the fallback. Health-check loop also pings +each provider periodically and records last-known-good status for +admin UI. + +**Complexity.** Genuine — failure detection is the hard part. 1–2 +days for the naive version, more for proper circuit-breaker logic. +Lower priority because most operators tolerate manual intervention +for brief outages. + +### 5. Multi-tenant Keysat boxes — "Keysat as a service" + +**Value.** Separate business shape: a SaaS provider runs ONE box +that hosts licensing for many INDEPENDENT operators (each with +their own merchant profiles, products, customers, branding, auth). +Different product than the operator-owned licensing server Keysat +is today. + +**What changes.** Almost everything — every table needs a +`tenant_id` (or `operator_id`), auth becomes per-tenant, the admin +UI becomes a tenant-scoped view, etc. + +**Recommendation.** Don't plan against this. If the SaaS shape ever +becomes interesting, it's a fork or v2.0 product, not a layered-on +feature. The merchant-profile schema we're building IS however a +useful foundation IF that pivot happens, because per-tenant +billing already maps to per-tenant set-of-merchant-profiles. + +--- + +The merchant-profile foundation we're shipping in this cycle is +deliberately shaped to make 1–3 above straight extensions. 4 is a +different kind of work (failure detection, not data model). 5 is a +different product. + +## How this composes with the SMTP / operator-alerts plan + +See `/Users/macpro/.claude/plans/keysat-smtp-emails.md` (companion +plan). + +- Per-profile SMTP override fields (in this migration) replace what + the SMTP plan originally placed on `products`. Email branding is + business-level, not product-level, so it belongs on + `merchant_profiles`. +- The SMTP plan's "buyer transactional emails" opt-in becomes a + per-profile flag (`merchant_profiles.keysat_sends_buyer_emails`) + rather than per-product. Master Keysat's profile defaults it to + on; Recaps profile defaults it off (Recaps handles its own buyer + emails via webhooks). +- The SMTP plan's operator alerts are unchanged — those go to the + operator personally (StartOS-level email), independent of + merchant profiles. + +Migration order: ship this plan first (merchant_profiles table +exists), then the SMTP plan layers per-profile SMTP + email +settings on top of the new table.