Initial commit — multi-provider + SMTP plans
This commit is contained in:
@@ -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.
|
||||||
@@ -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: 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.
|
||||||
Reference in New Issue
Block a user