Initial commit — multi-provider + SMTP plans

This commit is contained in:
Keysat
2026-05-20 12:51:31 -05:00
commit 3aacbbe278
2 changed files with 1037 additions and 0 deletions
+452
View File
@@ -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 <provider>").
- `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/<event>.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 <support@recaps.cc>" not
"Keysat <noreply@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<SmtpConfig>` 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.
+585
View File
@@ -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/<slug>`.
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<MerchantProfile>`,
`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<dyn PaymentProvider>`
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<dyn PaymentProvider>`
- Add a cache `provider_cache: Cache<String, Arc<dyn PaymentProvider>>`
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/<slug>`)
- 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: 23 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. 12
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 13 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.