Commit Graph

16 Commits

Author SHA1 Message Date
Grant d5885d1d97 Add merchant-onboard scoped-key role for self-serve onboarding
New scoped API-key role granting read + products:write + policies:write +
licenses:write — the least-privilege credential for end-to-end catalog
setup and license issuance (create product, define policies/tiers, issue
licenses against them) without holding the master key.

The catalog write scopes already existed and were enforced on the
endpoints; only the role->scope expansion was missing. So this is a new
Role variant, not a scope-model change. grants() matches scope strings
explicitly (never by :write suffix) so the role can't widen into
settings / payment / merchant-profile / webhook writes, and every
master-only operation stays behind require_admin and so is structurally
unreachable. Existing tier caps still bound it (Creator: 5 products /
5 policies per product).

Migration 0023 rebuilds scoped_api_keys to widen the role CHECK (SQLite
can't alter a CHECK in place); the table has no FKs, so it's a plain
copy/drop/rename. Test covers the full onboard chain under the key's own
credential plus denial of master-only gates and support-only writes.
2026-06-16 18:55:18 -05:00
Grant 9df1908328 WIP — BTCPay connect rewrite + webhook URL refactor + thank-you fix (part 3b)
Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.

migrations/0022_btcpay_state_profile.sql (new)
  Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
  the BTCPay OAuth state token can round-trip the operator's profile
  pick between start_connect and the callback. Without this, multi-
  profile operators couldn't authorize a SECOND BTCPay store onto a
  non-default profile.

btcpay/config.rs
  record_authorize_state takes merchant_profile_id; consume_authorize_state
  now returns Option<String> so the callback knows which profile to
  attach the new provider row to.

api/btcpay_authorize.rs (full rewrite)
  start_connect accepts an optional merchant_profile_id (defaulting to
  the default profile), refuses if that profile already has a BTCPay
  provider attached (unique-index-friendly 409 message), and records
  the profile id on the CSRF state token. The OAuth round-trip carries
  the profile id back via the state token, not via a query param —
  state-token-by-row is more robust than depending on BTCPay preserving
  redirect-URL query params during the consent dance.

  finish_connect (the callback's inner path):
    - Pre-generates the payment_providers row id so it can be baked into
      the BTCPay-side webhook callback URL.
    - The webhook URL we register with BTCPay is now path-keyed:
      /v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
      isolated deliveries.
    - INSERTs into payment_providers (kind='btcpay', api_key, base_url,
      webhook_id, webhook_secret, store_id, attached to the chosen
      profile) instead of upserting the singleton btcpay_config row.
    - Populates the back-compat state.payment singleton ONLY when this
      is the first provider on the default profile (so the few remaining
      legacy state.payment_provider() callers still work without a
      daemon restart).

  disconnect accepts an optional provider_id; defaults to "the BTCPay
  provider on the default profile" for back-compat with the existing
  admin UI's single Disconnect button. Best-effort BTCPay-side webhook
  + API key revocation unchanged. DELETE FROM payment_providers WHERE
  id = ? instead of clearing btcpay_config.

  status + payment_methods report on the default-profile BTCPay row for
  the legacy admin UI. Multi-profile operators will use the new
  /v1/admin/merchant-profiles endpoints (part 4).

api/webhook.rs
  Split into two entry points:
    - handle_for_provider — the new path-keyed shape
      (`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
      via state.payment_provider_by_id, validates the payload against
      THAT specific provider's secret, then runs the inner pipeline.
    - handle — back-compat for the bare /v1/{kind}/webhook path. Routes
      to whichever provider is on the default profile. Kept so any
      in-flight pre-:52 webhook delivery or admin misconfiguration
      doesn't silently drop on the floor.
  Both share an extracted handle_inner that does the actual settle /
  expire / refund processing.

api/mod.rs
  Route registrations:
    - Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
    - Removes the legacy /v1/admin/payment-provider/activate route
      (the shim function is gone).

  Thank-you page provider-kind lookup ports from the deprecated
  read_active_provider_preference to: invoice.payment_provider_id ->
  payment_providers.kind -> ProviderKind. Falls back to the default
  profile's first provider if the invoice predates migration 0021.

api/payment_provider.rs
  Reduced to just the back-compat status endpoint. The activate
  endpoint is removed entirely — there's no "active" preference to
  flip in the merchant-profile model. Status returns the same
  btcpay_configured / zaprite_configured / active shape the existing
  admin UI consumes, plus a new providers[] array for callers that
  want the full picture.

Build: cargo check passes. Only two warnings remaining — both
expected:
  - recover.rs unused-import (pre-existing, unrelated)
  - SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
    branch in read_active_provider_preference that runs during the
    pre-:52 upgrade window before migration 0020 has dropped the
    settings row)

What's left for :52:
  - New admin endpoints for merchant-profile + rail-preference CRUD
  - Admin UI in web/index.html (biggest remaining chunk — Merchant
    Profiles section + product picker + buy-page brand block +
    rail picker)
  - Tier-cap wire-up for unlimited_merchant_profiles
  - Version bump + release notes + sandbox test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:43 -05:00
Grant 7c4dfbacd2 WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.

migrations/0021_invoice_provider_link.sql (new)
  Adds invoices.payment_provider_id (nullable FK), backfills existing
  pending/settled rows to the earliest-connected provider on the default
  profile. Additive — no drops, no removals. Companion to 0020 from the
  foundation commit.

models.rs
  Invoice gains payment_provider_id: Option<String>.

db/repo.rs
  row_to_invoice reads the new column. All three invoice SELECTs include
  it. create_invoice + create_invoice_with_currency take a new optional
  payment_provider_id parameter and persist it on INSERT.

subscriptions.rs
  Subscription struct gains merchant_profile_id + payment_provider_id
  (snapshotted on create). SUB_COLS + row_to_subscription + the manual
  SELECT in find_lapsing_subscriptions all updated. create_subscription
  accepts both new fields and writes them on the INSERT row.

  renew_one — reads the sub's payment_provider_id snapshot and resolves
  the provider via state.payment_provider_by_id(). Falls back to the
  legacy state.payment_provider() for any subs created pre-:52 that
  the migration backfill missed.

  capture_zaprite_payment_profile — uses the INVOICE's provider, not
  "the active one." Saved-profile ids are scoped per Zaprite org; using
  the wrong provider would fail the lookup.

  try_auto_charge_zaprite — uses the sub's snapshotted provider (same
  rationale).

reconcile.rs
  Per-invoice provider lookup. Each pending invoice is reconciled
  against state.payment_provider_by_id(inv.payment_provider_id), with
  graceful fallback for NULL provider ids. No more single-global-
  provider assumption.

tipping.rs
  Tip pay-out uses the provider that settled the license's purchase
  invoice (joined via licenses.invoice_id). Same rationale as the
  capture hook — the tip needs to draw from the right LN node.

api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
  Tier-change invoices ride on existing licenses. The right provider
  is whichever the license's subscription is snapshotted to (so the
  proration charge settles to the same merchant identity that collects
  renewal fees). Falls back to the invoice's recorded provider, then
  the legacy default, for licenses with no subscription or pre-
  snapshot rows.

api/purchase.rs
  StartPurchaseReq gains an optional `rail` field
  ("lightning"/"onchain"/"card") for the future buy-page multi-rail
  picker. When omitted (today's behavior), the daemon picks the first
  rail the product's merchant profile exposes — which is correct for
  single-provider operators AND back-compat for any pre-:52 client
  not yet sending the field.

  Provider resolution: product → merchant_profile → rail →
  resolve_provider_for_profile_rail. The redirect_url defaults to the
  profile's post_purchase_redirect_url (with {invoice_id} substitution)
  if set, else Keysat's own /thank-you. New invoices carry their
  provider's id via the new create_invoice_with_currency parameter.

api/webhook.rs
  issue_license_for_invoice now passes snapshot fields when calling
  subscriptions::create_subscription — both merchant_profile_id (from
  product lookup) and payment_provider_id (from the invoice row).

main.rs
  Replaces the legacy "active provider preference" boot loader with a
  default-profile-first-provider warm-up. The legacy state.payment
  singleton stays populated for back-compat with call sites that
  haven't yet migrated to the on-demand resolution path. Pre-migration
  fallback to the old singleton-config loaders preserved so the
  daemon still boots cleanly on a DB that hasn't run 0020 yet.

Remaining for part 3:
  - BTCPay + Zaprite connect flows take merchant_profile_id and
    INSERT into payment_providers (currently still write to the
    dropped singleton tables, broken post-migration).
  - api/payment_provider.rs activate endpoint becomes irrelevant in
    the new model — repurpose as list-providers, or delete.
  - Thank-you page (api/mod.rs) provider-kind lookup ports to the
    invoice's recorded provider.
  - Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
  - Admin UI for Merchant Profiles + product picker + buy-page brand
    block + rail picker.
  - Tier-cap wire-up for unlimited_merchant_profiles entitlement.
  - Version bump to :52 + release notes.

Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:26:22 -05:00
Grant 04e0dcd591 WIP — merchant profile foundation (multi-provider payment model, part 1)
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.

This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.

What landed:

migrations/0020_merchant_profiles.sql
  Full schema + data port + DROP of the singleton tables. Creates
  merchant_profiles, payment_providers (FK to profile, unique per
  (profile, kind)), merchant_profile_rail_preferences (tie-breaker
  when a profile has 2 providers serving the same rail). Adds
  merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
  to subscriptions for the snapshot-on-create semantics. Ports
  btcpay_config + zaprite_config + active_payment_provider setting
  into the new tables, then drops them. Master operator post-migration
  step: update the Zaprite webhook URL on the Zaprite dashboard to
  the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
  Zaprite in the new UI once it ships).

src/merchant_profiles.rs (new module)
  MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
  input types. Business-logic CRUD helpers: create, get, get_default,
  require_default, list, update, set_default, delete, for_product.
  Delete refuses if products or active subs are attached or if it's
  the default profile. Tier-cap check stubbed with a TODO for the
  next chunk's tier.rs wire-up.

src/db/repo.rs (+469 lines)
  Repo helpers: create/get_by_id/get_default/get_for_product/list/
  update/set_default/delete for merchant_profiles + count helpers
  for products/active_subscriptions per profile. PaymentProviderRow
  struct + create/get/list_for_profile/list_all/delete. RailPreference
  struct + list/set/clear helpers. update_merchant_profile builds a
  dynamic SET clause so partial updates don't clobber fields the
  caller didn't touch.

src/payment/mod.rs
  Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
  rails_for_kind static mapping. build_provider(row, public_base) ->
  Arc<dyn PaymentProvider> factory that dispatches on kind to construct
  a typed BtcpayProvider or ZapriteProvider from a payment_providers
  row. PaymentProvider trait gains a default served_rails() impl
  returning rails_for_kind(self.kind()).

  Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
  read_active_provider_preference + write_active_provider_preference
  stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
  thank-you page still build. read_active_provider_preference now
  reads from the new payment_providers table (returns the kind of
  the first provider attached to the default profile), falling back
  to the legacy settings-table read pre-migration. write_* is a no-op.
  Each shim has a #[deprecated] attribute so the build surfaces
  exactly which call sites still need porting (lit up in the
  follow-up cycle's TODO).

src/api/mod.rs (AppState)
  New methods alongside the existing payment_provider() shim:
    - payment_provider_by_id(id) — looks up a row, builds the provider
    - merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
    - resolve_provider_for_profile_rail(profile_id, rail) —
      preference table -> single candidate -> deterministic earliest-
      connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
    - resolve_provider_for_product_rail(product_id, rail) — convenience
      wrapping the previous two.

src/lib.rs
  Registers the new merchant_profiles module.

Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:00:00 -05:00
Grant fea6995192 v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI
Two routine bumps land together in this release:

:44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and
hamburger-driven off-canvas drawer (≤720px) to the embedded
web/index.html so triage flows (status check, license lookup, revoke)
work from a phone. Tables now scroll horizontally inside their card,
tap targets bump to ~40px, stats grid collapses to 1-up, toolbar
inputs go full-width. Desktop layout unchanged. CSS + small JS toggle.

:45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap
the subscriptions.rs module comment promised but never delivered:
first-cycle invoices on recurring policies set allow_save_payment_profile,
the on-settle hook captures the resulting Zaprite paymentProfileId
into four new nullable columns on the subscriptions table (migration
0019, additive only), and the renewal worker calls
POST /v1/orders/charge against the saved profile instead of waiting
for manual pay. On charge failure (declined card, expired profile,
network) the worker logs + audits + falls through to the existing
subscription.renewal_pending event so the buyer still has a recovery
path. Two new operator webhook events: subscription.auto_charge_initiated
and subscription.auto_charge_failed. BTCPay subs and Zaprite subs
whose buyer paid with Bitcoin/Lightning or declined the save-card
prompt are untouched. NOT yet end-to-end tested against the Zaprite
sandbox — control flow follows api.zaprite.com/llms.txt but exact
failure-body shapes for declined cards aren't documented; sandbox
validation pass recommended before relying in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:20:53 -05:00
Grant 094cf75e52 v0.2.0:20 — Multi-policy scope for discount codes
A discount code can now apply to a subset of policies on a product
(e.g. "Patron and Pro but not Creator") instead of being limited to
exactly one policy or the entire product.

- Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array
  of policy ids). Legacy `applies_to_policy_id` stays as the singular
  fallback when the JSON column is empty/NULL.
- `DiscountCode::allowed_policy_ids()` helper unifies multi + singular
  into one Vec. Purchase + preview scope checks consult it.
- `find_applicable_featured_discount` now narrows multi-policy
  candidates in Rust (small candidate set; index-friendly SQL would
  require json_each, deferred).
- Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs`
  (array) alongside the existing `policy_slug` (singular). Multi wins
  when both are present. PATCH does not allow scope edits — same rule
  as the singular field (disable + recreate to re-scope).
- UI: pill multi-select replaces the policy dropdown on the create
  form. Edit modal's scope label renders the comma-separated list.

UI + schema both back-compat: existing codes keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:01:51 -05:00
Grant 4334a9f044 v0.2.0:16 — Launch-special discount codes + marketing bullets
Major feature release.

Featured (launch-special) discount codes:
  - New 'featured' flag on discount_codes (migration 0017). When true,
    the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
    original price + new price for every applicable tier. Purchase
    endpoint auto-applies the discount for buyers who don't type a
    code. Operator-typed codes still win.
  - find_applicable_featured_discount repo helper: most-specific match
    (policy > product > global), tiebreak by created_at.
  - GET /v1/products/<slug>/policies now returns featured_discount per
    policy with the post-discount price computed server-side. SDK
    consumers + the dynamic pricing page get this for free.

Marketing bullets on policies:
  - metadata.marketing_bullets — operator-controlled copy that renders
    as additional checkmarks above the entitlement bullets on both the
    admin grid tier card and the buy page tier. For things like 'Up
    to 5 products' or 'BTCPay integration' that aren't real
    entitlement gates.
  - Authored via textarea on draft + edit policy forms.

UI:
  - 'Most popular' checkbox now on the draft tier card (was edit-only).
  - Discount codes tab grouped by product (matching Licenses /
    Subscriptions tabs). Each code row gets a 'featured' badge when
    flagged.

All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
2026-05-11 12:47:45 -05:00
Grant 257669092b v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe-
delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings
tab + agent-friendly operator API + machines tab redesign + buyer-facing
copy alignment).

Highlights:

- Migration 0015: policies.archived_at column. Archive button on tier
  cards; safe-delete relaxed to ignore revoked-license tombstones;
  renewal worker refuses archived policies.
- Migration 0016: scoped_api_keys table. Four roles (read-only,
  license-issuer, support, full-admin) with bounded scopes. Master
  admin_api_key still works on every endpoint; scoped keys gated on
  endpoints wired through require_scope().
- New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec
  for agent / SDK discovery.
- New Settings tab: Operator name + Payment providers panel + API
  keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay
  all, operator name, switch-provider). StartOS Actions pruned to 4
  install-time essentials.
- Machines tab rewritten: global default view grouped by product,
  filter pills with counts, quick-stats row, drill-down via new
  "Machines" button on each Licenses-tab row. New repo helper
  list_machines_admin joins machines x licenses x products
  server-side.
- Branded confirmModal replaces every native window.confirm() call
  in the admin UI (7 callsites).
- Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag
  retired; daemon always boots; missing self-license -> Creator
  (free) tier. "Unlicensed" label gone from admin UI.
- Zaprite gated on the new zaprite_payments entitlement (renamed
  from card_payments to reflect the broader gateway).
- Creator code cap 5 -> 10.
- KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope,
  webhook events, worked recipes.
- Buyer-facing copy aligned with new positioning: "Bitcoin-native
  self-hosted software licensing" everywhere on production surfaces.
- Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md.
- 5 new API integration smoke tests covering OpenAPI, scoped API
  keys CRUD, role-elevation guard, and Zaprite-tier gating.

Test count: 83 passing (was 78). All migration tests pass against
0015 and 0016 applied to populated DBs.
2026-05-11 08:45:25 -05:00
Grant 68dfe7f6fc Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").

Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
  as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
  catalog from the union of its policies' entitlement slugs, with
  name = slug.replace('_', ' ') and empty description. Operators
  can refine afterward.
- Products with no policy entitlements stay NULL (legacy
  free-text mode preserved).

Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
  slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
  update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
  policy create + update reject any entitlement slug not in the
  catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
  in the product object so SDK consumers can render display
  names client-side
- Buy page renders entitlement display names + description tooltips
  on tier cards (falls back to raw slug for legacy entries that
  predate the catalog)

Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
  with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
  showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
  product's catalog — bubble picker when catalog has entries,
  legacy textarea otherwise. Rebuilds when operator changes
  product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
  to the policy's product
- Policy list table: entitlement column shows display names
  (resolved against the product's catalog) instead of slugs

Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
  policies, deduplicates, applies name = slug-with-underscores-as-
  spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration

Test count: 78 (was 77; +1 for migration_0014_backfills_*).

Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
2026-05-10 07:55:14 -05:00
Grant 8ce78ab9d3 Tier upgrades Phase 1 — schema foundation (dormant)
First step of TIER_UPGRADES_DESIGN.md (Grant + me, parent folder).
Schema-only commit; Phases 2-6 (quote logic, buyer endpoints, admin
endpoints, admin UI, buyer surface) ship in follow-ups.

Migration 0013_tier_upgrades.sql:

1. ALTER TABLE policies ADD COLUMN tier_rank INTEGER. Operator-defined
   ladder ordering — higher = better tier. NULL means the policy isn't
   in any ladder (existing operators see no behavior change). The
   buyer-facing upgrade endpoint will validate
   target.tier_rank > current.tier_rank for upgrades, and the reverse
   for downgrades. Index on (product_id, tier_rank) supports the
   "list this product's policies in ladder order" query.

2. New tier_changes table — one row per upgrade/downgrade. Captures:
   - from_policy_id / to_policy_id with FKs into policies
   - direction ('upgrade' | 'downgrade', CHECK enforced)
   - listed_currency + proration_charge_value (smallest unit) for the
     pricing snapshot; invoice_id nullable so comp-mode admin changes
     (skip_payment=true) can write a row without an invoice
   - effective_at decoupled from created_at so downgrades on recurring
     subs can be RECORDED immediately but TAKE EFFECT at cycle end
   - actor ('buyer' | 'admin', CHECK enforced) + free-form reason
   - Three indexes covering the obvious query paths: by license
     (history view), by created_at (operator analytics), partial on
     invoice_id WHERE NOT NULL (webhook-handler lookup of
     "is this settling invoice a tier-change?").

Migration regression test (8 tests now in tests/migrations.rs, was 7):
- Existing pre-0013 fixtures untouched, tier_rank defaults to NULL.
- tier_changes accepts a row referencing pre-0013 license/policy/invoice.
- CHECK constraints fire: bad direction, bad actor, negative
  proration_charge_value all rejected.
- assert_db_clean confirms no FK / integrity drift.

Drive-by: branding design doc (parent folder) bumps its migration
number from 0013 → 0014 to avoid a collision with this one.

Test count: 58 (was 57; +1 for migration_0013_adds_tier_upgrades).
2026-05-08 19:33:08 -05:00
Grant 9eba309a8f v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation
This release adds Zaprite as an alternative to BTCPay. Operators
can now choose between two payment rails:
- BTCPay: Bitcoin-only, you run the BTCPay Server yourself
- Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered
  by Zaprite, settles to your connected wallets

Only one is active at a time per Keysat instance. Switching requires
Disconnect → Connect; existing license keys are unaffected. Future
v0.3 work routes per-policy choice (e.g., "free tier via Zaprite,
paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's
either-or.

What's in this release:

**Migration 0011 — recurring subscriptions schema (dormant).**
Adds `subscriptions` and `subscription_invoices` tables, plus
`is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/
`trial_days` (default 0) on policies. No daemon code uses these
yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in
follow-up commits. Migration regression test covers the additive
contract against populated data.

**Migration 0012 — zaprite_config.** Singleton-row table for the
operator's Zaprite API key + base URL + recorded webhook id.
Mirrors btcpay_config from migration 0002.

**ZapriteProvider implementation.** New module at
src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs
(DB persistence), provider.rs (PaymentProvider trait impl). Maps
Zaprite's currency enum (BTC/USD/EUR) to/from the Money type;
maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/
OVERPAID/UNDERPAID) to ProviderInvoiceStatus.

**Webhook security via externalUniqId round-trip.** Zaprite does
NOT publish a webhook signature scheme (verified May 2026 against
public OpenAPI + dashboard). Their docs explicitly designate
receiver-side idempotency as the security model. Keysat's defense:
attach our local invoice UUID as externalUniqId at order creation,
then trust the webhook only insofar as the order id resolves to
a local invoice in an expected state. Documented in detail in the
payment::zaprite module-level comment + the validate_webhook
docstring.

**Admin endpoints.**
- POST /v1/admin/zaprite/connect: validates the API key by pinging
  GET /v1/orders before persisting; swaps active provider atomically
- POST /v1/admin/zaprite/disconnect: clears stored creds + provider
- GET  /v1/admin/zaprite/status: read-only connection snapshot
- POST /v1/zaprite/webhook: webhook landing route (alias of the
  existing /v1/btcpay/webhook handler since validate_webhook is
  trait-level)

**StartOS Actions** under a new "Zaprite" group: Connect Zaprite,
Check Zaprite connection, Disconnect Zaprite. Operator pastes the
API key into a masked input; daemon validates + saves.

**Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing
covers the full event-type mapping + missing-id rejection +
malformed-JSON rejection; zaprite_provider_kind pins the
identification). Migration regression test for 0011. Test count
grows 39 → 41.

Operators on BTCPay see no change. Operators wanting Zaprite go
through the StartOS Actions tab → Connect Zaprite, paste their
API key, register a webhook in Zaprite's dashboard pointing at
their public Keysat URL + /v1/zaprite/webhook.

Recurring subscriptions are NOT yet operator-visible — schema only
in this release. Daemon-code that uses the subscriptions tables
(renewal worker, validate-hot-path subscription branch, admin UI)
lands in subsequent commits per the design doc's phased plan.
2026-05-08 16:34:58 -05:00
Grant 4251e96082 Migration 0011 — recurring subscriptions schema (committed, not published)
Foundation-only commit. Adds the storage shape for recurring-billing
licenses; daemon code that uses these tables (renewal worker,
validate-hot-path subscription branch, admin endpoints, buy-page
recurring rendering) lands in subsequent commits.

Schema changes (all additive):
- policies gains: is_recurring, renewal_period_days,
  grace_period_days (default 7), trial_days (default 0).
- New table `subscriptions` — one row per subscription-backed
  license (1:1 via license_id UNIQUE). Tracks the cycle state
  machine: active / past_due / cancelled / lapsed.
- New table `subscription_invoices` — one row per renewal-cycle
  invoice. Joins subscriptions to the existing invoices table.
  UNIQUE(subscription_id, cycle_number) prevents double-billing
  the same cycle.

Pricing snapshot (listed_currency / listed_value / period_days)
is FROZEN at subscription creation. Operator changing the
underlying policy's price doesn't affect existing subs; the next
renewal still bills the snapshotted amount. Per
RECURRING_SUBSCRIPTIONS_DESIGN.md.

Migration regression test (migration_0011_adds_subscriptions_without
_breaking_existing_data) seeds realistic fixtures pre-0011, applies
0011, asserts:
  - existing policies default to non-recurring with grace=7,
    trial=0
  - new tables accept rows via FKs into pre-0011 license/policy/
    invoice rows
  - status CHECK rejects garbage values
  - subscription_invoices UNIQUE(sub_id, cycle_number) prevents
    duplicate cycle inserts
  - foreign_key_check + integrity_check both clean post-migration

Test count: 39 (was 38). Tests all pass:
  9 unit + 16 API + 4 crosscheck + 7 migration + 3 worker.

Defaults encoded:
  - grace_period_days = 7  (per RECURRING_SUBSCRIPTIONS_DESIGN
    open question 1; my recommended default)
  - trial_days included as a column from day 1 (per open question
    3; cheaper to ship now than migrate later)
  - cancellation refund: not a schema concern — just stops next
    charge, license stays valid through current cycle (per
    open question 2; my recommended default)

If Grant comes back with different answers, the defaults can be
tuned via ALTER COLUMN DEFAULT in a follow-up migration. Existing
subscriptions wouldn't be affected (they snapshot grace_period_days
at creation in their policy_id reference, not directly in the
subscription row — this might need rethinking once the renewal
worker lands; flagged for the next pass).

Not bumped / published — operator-visible only once the daemon
code that uses these tables ships. Ready to publish whenever
Grant approves the open-question defaults.
2026-05-08 14:05:44 -05:00
Grant d8fcb51d1c Multi-currency schema foundation (Phase 1 of MULTI_CURRENCY_DESIGN)
Migration 0010 adds the columns needed to price products + policies
in something other than satoshis (USD, EUR, BTC at higher denoms)
while keeping every existing operator's data behaviorally identical.
This is the foundation work; admin UI write path, buy page
rendering, and rate fetcher land in subsequent phases. See
MULTI_CURRENCY_DESIGN.md at the parent licensing/ folder for the
full design.

Schema changes (all additive):
- products gain price_currency (TEXT NOT NULL DEFAULT 'SAT') and
  price_value (INTEGER NOT NULL DEFAULT 0). Backfill copies
  price_sats → price_value on every existing row, so SAT-priced
  products carry their information identically through the
  migration.
- policies gain price_currency_override (nullable, NULL = inherit
  from product) and price_value_override (nullable, mirrors the
  existing price_sats_override).
- invoices gain four nullable columns: listed_currency, listed_value,
  exchange_rate_centibps, exchange_rate_source. NULL on every
  current row; populated by the daemon when an invoice is created
  against a fiat-priced product.
- discount_codes gains discount_currency (DEFAULT 'SAT'). 'percent'
  codes are currency-agnostic; 'fixed_sats' and 'set_price' codes
  use this column to express "$10 off" or "set price to $25"
  against fiat-priced products.
- New index idx_products_currency for future "list products by
  currency" admin views.

Read path:
- Product struct gains price_currency + price_value fields
  (#[serde(default)] for back-compat with any cached/persisted
  shapes that predate them).
- row_to_product extracts the new columns; falls back to SAT/
  price_sats if a row predates 0010 (defensive — migration always
  runs at boot, but no reason to crash if it didn't).
- All four product SELECTs add the new columns.

Write path (legacy SAT-only callers):
- create_product dual-writes price_sats AND price_value to the
  same value, with price_currency = 'SAT'.
- update_product dual-writes price_sats and price_value when the
  caller passes a new sat price.

Migration regression test:
- migration_0010_backfills_existing_products_to_sat seeds three
  products (free, $100, $2500-equivalent) and a policy with a
  sat override BEFORE 0010 runs, applies 0010, asserts every row
  ends up with price_currency = 'SAT' and price_value =
  price_sats. Catches any future change that breaks the
  backfill contract.
- migration_0009_is_idempotent now pinned to 0009 by filename
  (was: "the last migration"). 0010+ are not idempotent (ALTER
  TABLE ADD COLUMN can't be retried in SQLite); the
  idempotency test is specifically for 0009 because that
  migration's whole point was being safely re-runnable.

Test count: 33 (was 32; +1 migration_0010_backfills test).

Decisions locked in (per MULTI_CURRENCY_DESIGN open questions):
- Default currency on new products: SAT. Operators explicitly
  pick USD for fiat-priced products.
- Multi-currency available to all tiers (NOT gated behind Pro/
  Patron) — the right product call.
- Rate source priority: Kraken → Coinbase → CoinGecko (lands
  in Phase 4 of the design).
- Recurring subscriptions: SAT-priced subs charge the same sat
  amount each cycle (no rate adjustment needed); USD-priced subs
  re-quote each cycle so the dollar amount is stable.
2026-05-08 12:00:13 -05:00
Grant 116ed0d1f8 v0.1.0:41 — second hotfix to migration 0009; migration regression tests
The v0.1.0:40 migration was correct on clean installs but crashed at
COMMIT on any database with rows in discount_redemptions: SQLite's
deferred FK check saw the dropped parent's bookkeeping as unsatisfied
even after the rename. Fix is to rebuild discount_redemptions in the
same transaction (stash → drop → rebuild → restore) plus orphan
cleanup. Migration is idempotent; operators on :40 with a checksum
mismatch recover by deleting the version=9 row from _sqlx_migrations
and restarting.

Lands the missing migration test scaffolding too. The four tests in
licensing-service/tests/migrations.rs apply migrations against a
realistic populated database (products, policies, invoices, licenses,
machines, discount codes, redemptions, webhooks, tip attempts). The
regression test fails with the exact 787 error against the v40
migration — would have caught the bug pre-release.

KEYSAT_INTEGRATION.md is removed from this repo; it now lives in the
parent licensing/ folder.
2026-05-08 08:05:19 -05:00
Grant beedd07f07 v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions 2026-05-07 23:35:22 -05:00
Grant 6ac118ae70 v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
2026-05-07 10:33:39 -05:00