Commit Graph

51 Commits

Author SHA1 Message Date
Grant 58939d1dc6 v0.2.0:5 release notes — tier upgrades functional end-to-end
Bumps the milestone version + writes the operator-facing release
notes covering the complete tier-upgrades feature delivered across
8ce78ab (Phase 1 schema), f8affdb (Phase 2 quote/apply), b7fa6c7
(Phase 3 buyer endpoints + webhook), c5d716a (Phase 4 admin
endpoint + renewal-worker hook), and fb062d5 (Phase 5 admin UI).

Test count callout: 77 (was 57 at v0.2.0:4).
2026-05-08 20:16:14 -05:00
Grant fb062d5ca5 Tier upgrades Phase 5 — admin UI: tier_rank input + Change-tier modal
Closes the operator surface for tier upgrades. With this in,
operators have a complete UI for managing the upgrade ladder
without ever needing the curl-the-API path.

Policy editor (create + edit forms):
- New "Tier ladder rank (optional)" number input alongside the
  recurring section. Operators set "0" for free, "1" for
  standard, "2" for pro, etc. Empty input = "not in any ladder"
  (server stores NULL; that policy is excluded from buyer-facing
  upgrade flows but admin can still force-change to/from it).
- Edit-form behavior: empty input clears tier_rank to NULL.
  Filled input sets to that value. The PATCH always sends the
  field (using the nullable-patch shape Some(Option<i64>)) so
  the operator's intent — clear or set — actually lands.
- Range 0–1000 enforced server-side; clipped client-side too.

Licenses page:
- New "Change tier" button on every non-revoked license row,
  to the left of Suspend/Unsuspend/Revoke.
- Opens a modal that:
    * Loads all policies for the license's product
    * Shows them in a dropdown with metadata (rank · cadence ·
      trial flags) so the operator can see the ladder shape
    * Offers a "Apply as comp (skip_payment=true — no invoice,
      flips immediately)" checkbox + an audit-reason field
    * On submit, POSTs to the new admin endpoint:
      - skip_payment=true → "Applied" status, modal closes
      - skip_payment=false → renders the checkout URL the
        operator forwards to the buyer through whatever channel
        they use (the design-doc-spec'd "operator delivers the
        URL" flow)
- The modal deliberately doesn't show a quote preview before
  submit (the buyer-quote endpoint requires the buyer's signed
  license key, which the admin doesn't have). Server-side
  response carries the actual numbers when the operator commits.
  Future polish: a separate admin-mode quote endpoint could
  render the preview pre-submit.

Tests unchanged (77 still passing) — pure UI commit, no Rust
changes. The behavior the UI drives is fully covered by the
api.rs admin_change_tier_* tests added in c5d716a.
2026-05-08 20:15:23 -05:00
Grant c5d716a6d4 Tier upgrades Phase 4 — admin force-change + renewal-worker hook
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.

New endpoint:
- POST /v1/admin/licenses/:id/change-tier
  Body: { to_policy_slug, skip_payment: bool, reason?: string }

  skip_payment=true (comp upgrade / support fix-up): apply
  immediately, write a tier_changes row with proration=0 and
  invoice_id=NULL, fire the license.tier_changed webhook, audit-log
  with actor=admin_api_key.

  skip_payment=false: same as buyer's /v1/upgrade — create a
  provider invoice for the prorated charge, persist the local
  invoice + a tier_changes row tied to it, return the checkout URL.
  Operator forwards it to the buyer through whatever channel they
  use. Webhook applies on settle.

  Bypasses ladder rules entirely (sideways, perpetual downgrade,
  recurring → perpetual all OK). Same-product / different-policy /
  active-target checks still apply.

QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
  from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.

Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
  apply_pending_tier_changes(state, sub). This finds tier_changes
  rows for the sub's license where effective_at <= now AND
  invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
  scheduled comp/admin changes that haven't been applied yet).
  Each pending change is applied via apply_tier_change (which
  also rewrites the sub's policy_id / listed_value / period_days).
  After applying, the worker re-fetches the sub and prices the
  next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
  the cycle boundary (admin records "Pro → Standard at next
  renewal", the worker applies it, the new invoice bills at
  Standard's price).
- Idempotent: re-running the hook on a license already on the
  target tier finds zero pending rows (the policy_id != check
  filters them out).

Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
  flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
  buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
  attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
  the headline behavior: a pending downgrade tier_change with
  effective_at=now causes the next renewal to bill at the new
  (lower) tier's price, NOT the old one. Uses a CapturingProvider
  mock that stashes the last sat amount it saw so the assertion
  is on what the worker actually billed.
2026-05-08 20:12:44 -05:00
Grant b7fa6c7dae Tier upgrades Phase 3 — buyer-facing HTTP endpoints
Closes the buyer self-service tier-upgrade loop. With this in,
SDKs can wire an "Upgrade to Pro" button inside the operator's
app and the daemon handles quote → invoice → settle → apply
without operator involvement.

New endpoints (auth via signed license_key in body, same model
as /v1/recover and /v1/subscriptions/cancel — no admin token,
no cookie):

- POST /v1/upgrade-quote   — read-only quote. "If I upgraded to
                             <tier>, what would I owe right now,
                             when do entitlements take effect,
                             what will the next renewal charge?"
- POST /v1/upgrade         — buyer commits. Daemon recomputes the
                             quote (don't trust client shaping),
                             rejects 0-charge upgrades (admin path
                             only), creates a provider invoice for
                             the prorated charge in the listed
                             currency converted to sats, persists
                             the local invoice + a tier_changes
                             row tying them together, returns the
                             checkout URL.

Webhook handler change (src/api/webhook.rs):
- On invoice settle, BEFORE the subscription / license-issuance
  branches, look up the invoice in tier_changes via
  upgrades::get_tier_change_by_invoice. If present, run the
  apply path: mutate the existing license's policy_id +
  entitlements + max_machines + grace + expires_at, mutate any
  tied subscription's policy_id + listed_value + period_days
  (so future renewals charge the new tier), audit, fire the new
  `license.tier_changed` webhook event, ack 200.
- Idempotent: re-delivered webhook on an already-applied
  tier change is a no-op (license.policy_id == target.id check).
- Critically: the existing license_id is preserved. Buyers
  keep the same signed key; on next online validation their
  app sees the new entitlements. No new license is issued.

Phase 3 scope deliberately excludes:
- Buyer-initiated DOWNGRADES. compute_upgrade_quote already
  returns 0-charge quotes for recurring downgrades (effective at
  next_renewal_at), but applying that at the cycle boundary
  needs renewal-worker integration. Phase 4 lands the admin
  endpoint AND the worker hook in one go. For v0.2.x the buyer
  endpoint rejects with 400 "admin-only".
- Admin force-change (POST /v1/admin/licenses/:id/change-tier).
  Phase 4.

Tests (+6, total now 72):
- upgrade_quote_returns_perpetual_difference (Standard $25 →
  Pro $75 = $50 = 5000 cents quote, "immediate" effective)
- upgrade_quote_rejects_garbage_key (401, doesn't leak whether
  the target slug exists)
- upgrade_quote_rejects_unknown_target_policy (404)
- upgrade_start_creates_invoice_and_tier_change_row (verifies
  the tier_changes row is written tied to the new invoice; the
  license is NOT yet on Pro until settle)
- webhook_settle_on_tier_change_applies_instead_of_issuing
  (full end-to-end: settle webhook fires → license flips to Pro
  + Pro entitlements appear; license count stays at 1, NO new
  license issued; re-delivery idempotent)
- upgrade_endpoint_rejects_buyer_downgrade (400 "admin-only" —
  the clear-message path the quote function intercepts with;
  Phase 4 will introduce a separate buyer-downgrade path)
2026-05-08 20:06:13 -05:00
Grant f8affdb11f Tier upgrades Phase 2 — quote logic + apply step
Builds on 8ce78ab (Phase 1 schema). Pure module work — no HTTP
endpoints yet (those are Phase 3). Operator-invisible until Phase
3-5 wire up the buyer / admin / UI surfaces.

src/upgrades.rs:
- UpgradeQuote / TierDirection / EffectiveAt structs (serde-ready
  for the future endpoint).
- compute_upgrade_quote(state, license, target_policy) — the
  buyer-facing quote function. Enforces ladder rules:
    * both policies must have non-NULL tier_rank
    * sideways (same-rank) changes rejected — admin-only
    * cross-product target rejected
    * inactive target rejected
    * same-policy noop rejected
    * perpetual downgrades rejected (refund decision = admin-only)
    * recurring → perpetual downgrade rejected (admin-only)
- Branches on perpetual vs recurring:
    * Perpetual upgrade: flat (target - current) listed price diff,
      effective_at = Immediate.
    * Recurring upgrade: prorated (target - current) × days_remaining
      / period_days; effective_at = Immediate; surfaces
      next_renewal_charge for the buyer to see what they'll pay
      going forward.
    * Recurring downgrade: zero-charge today, effective_at =
      next_renewal_at (full current cycle at old price).
    * Free → recurring: full first-cycle price (no proration since
      "remaining value" of free is 0).
- record_tier_change — INSERT helper for the audit row.
- apply_tier_change — UPDATE helper that mutates the license row
  (policy_id, entitlements_json, expires_at, max_machines,
   grace_seconds, is_trial) and any tied subscription
  (policy_id, listed_value, period_days). Recurring → perpetual
  apply also cancels the now-orphaned subscription so the renewal
  worker stops touching it.
- get_tier_change / list_tier_changes_for_license /
  get_tier_change_by_invoice — read helpers (Phase 3 webhook
  handler will use the by_invoice variant).

tier_rank threading:
- models::Policy gains `tier_rank: Option<i64>`.
- POLICY_COLS + row_to_policy include tier_rank with try_get
  Option<i64> + flatten so NULL stays NULL (a valid state) and
  pre-0013 databases also resolve to None.
- repo::create_policy gets a `tier_rank: Option<i64>` param.
- repo::RecurringUpdate gains `tier_rank: Option<Option<i64>>`
  for nullable-patch semantics matching price_sats_override.
- CreatePolicyReq + UpdatePolicyReq accept tier_rank with the
  same shape; range-validated 0..=1000.

tests/upgrades.rs (8 new tests):
- perpetual_upgrade_quote_returns_flat_price_difference
- perpetual_downgrade_is_admin_only (rejection w/ helpful msg)
- quote_rejects_target_with_null_tier_rank
- quote_rejects_same_policy
- recurring_upgrade_prorates_against_time_remaining (asserts
  ~half-of-diff for ~half-of-cycle remaining; tolerance window)
- recurring_downgrade_is_zero_charge_at_next_cycle (verifies
  effective_at lands on next_renewal_at)
- apply_tier_change_mutates_license_and_subscription (Standard
  monthly → Pro annual changes max_machines, entitlements,
  expires_at, sub policy_id + listed_value + period_days)
- record_and_lookup_tier_change_round_trip

Test count: 66 (was 58; +8).
2026-05-08 19:50:04 -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 938eedc99f Mobile responsiveness pass — buy / recover / thank-you
The recurring-subs work just added new tier-card content (cadence
line + trial banner + /mo suffix), so a quick pass on the three
buyer-facing pages was timely. Targeted, CSS-only changes.

Buy page (`/buy/<slug>`):
- h1 uses clamp(28px, 7vw, 42px) so it scales smoothly from phones
  to desktop without cliff-edge breakpoints. The fixed 42px was
  cramping 360-380px viewports.
- New @media (max-width:480px) breakpoint tightens the outer rhythm:
  topbar padding, wrap margin, cert padding, price size, tier-card
  padding, etc. The desktop 48px outer + 32px cert padding ate too
  much of a small viewport.
- Form input font-size pinned to 16px on mobile so iOS Safari
  doesn't auto-zoom when the buyer taps the email or discount field.
  (iOS zooms on any <16px input, which interrupts the buy flow.)
- Tier picker already had a 560px breakpoint dropping to 1-column;
  unchanged.

Recovery page (`/recover`):
- Default input/button font-size raised to 16px (iOS zoom fix).
- New @media (max-width:480px) breakpoint reduces outer body
  padding (48px → 24px) and main padding (32px → 22px), tightens
  h1 + label, and bumps button padding for thumb-friendly tap
  targets.

Thank-you page (`/thank-you`):
- Adds a @media (max-width:480px) block — previously it had zero
  breakpoints. Mirrors the buy-page pattern: tighter topbar, wrap
  margin, card padding, h1 fluid scaling, lede + footer sizing.

Admin UI is operator-side and not addressed in this pass. Could be
revisited if operators report mobile pain points; for now the
buyer-facing surface is the priority because that's where buyers
actually arrive on phones.
2026-05-08 18:07:06 -05:00
Grant 6112618c1b v0.2.0:4 release notes — recurring subscriptions functional end-to-end
Bumps the milestone version + writes the operator-facing release
notes covering the complete recurring-subs feature delivered across
7007bf8 (Phase 2 worker), c301eac (Phase 4 admin UI + buy page),
5d7f68f (Phase 6 cancellation backend), and 4bdc506 (Phase 6 cancel
UI).

Test count callout: 57 (was 42).
2026-05-08 18:04:51 -05:00
Grant 4bdc5066f7 Phase 6 UI — Subscriptions tab + cancel-with-reason button
Closes the cancellation UX loop opened by 5d7f68f. Operators can now:
- See all subscriptions on a dedicated sidebar tab (with status filter
  pills: All / Active / Past due / Cancelled / Lapsed)
- One-click cancel an active or past_due sub via the row's Cancel
  button (a confirm dialog also captures an optional reason for the
  audit log)
- See cadence (monthly / quarterly / annual / every Nd), listed
  price (in original currency), next renewal, and consecutive
  failures at a glance

Cancel button is hidden on already-cancelled and lapsed rows. Status
badges color-coded: green=active, amber=past_due, neutral=cancelled,
red=lapsed.

The reason prompt uses the browser's built-in `prompt()` for the
v0.2.x cut — small modal upgrade in a follow-up if operators ask
for richer affordances (buyer-vs-admin attribution dropdown, etc.).
2026-05-08 17:56:17 -05:00
Grant 5d7f68fef8 Recurring subs Phase 6 — cancellation flow (admin + buyer self-serve)
Closes the recurring-subs feature loop: operators can cancel subs from
the admin UI, buyers can self-cancel by submitting their signed
license key. Cancellation is non-destructive — the license stays
valid through end-of-cycle, the renewal worker just stops creating
new invoices because its WHERE filter excludes status='cancelled'.

New API
- GET  /v1/admin/subscriptions             — list (filter: status=...)
- POST /v1/admin/subscriptions/:id/cancel  — operator cancel (audited)
- POST /v1/subscriptions/cancel            — buyer self-service; auth
                                             via license_key in body,
                                             verified by signature

Repo helpers (src/subscriptions.rs)
- get_subscription_by_id
- get_subscription_by_license_id  (1:1 unique on license_id, used by
                                   buyer self-service)
- list_subscriptions(status_filter, limit)
- cancel_subscription              (idempotent UPDATE, returns whether
                                    it actually transitioned)

Behavior details
- Both endpoints fire `subscription.cancelled` webhook with
  actor=admin/buyer so operators can distinguish self-service.
- Audit log differentiates by actor_kind: 'admin_api_key' vs
  'buyer_license_key'.
- Buyer endpoint returns 401 (not 404) on bad/wrong key so a probe
  can't enumerate which licenses have active subs.
- Buyer endpoint returns 401 on revoked or suspended licenses too —
  same reason.
- Admin endpoint returns 200 with `{already: <prior_state>}` on
  re-cancel (idempotency); 404 on unknown sub.

Tests (+4, total now 57)
- admin_cancel_subscription_happy_path: full flow + DB invariants +
  audit row + idempotency
- admin_cancel_unknown_subscription_404s
- buyer_cancel_subscription_via_license_key: full flow + actor_kind
- buyer_cancel_rejects_garbage_key: 401 not 404

Admin UI for the cancel button + subscriptions tab lands in a
follow-up commit (kept this one to the API surface so it's reviewable
in isolation).
2026-05-08 17:53:42 -05:00
Grant c301eacfaa Recurring subs Phase 4 — admin UI + buy-page rendering + Pro-tier gate
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit 7007bf8) through every layer operators
and buyers actually see:

API
- Policy struct + repo gain is_recurring, renewal_period_days,
  grace_period_days, trial_days. RecurringConfig / RecurringUpdate
  helper structs keep create_policy / update_policy signatures
  manageable.
- CreatePolicyReq + UpdatePolicyReq accept all four fields. Validation
  rejects internally inconsistent combos (recurring=true with period=0,
  trial > renewal period, period >5y, grace >90d).
- New tier::enforce_recurring_feature gate. Pro/Patron only — Creator
  and Unlicensed get a 402 with upgrade_url. The gate fires on both
  create-policy and the false→true transition in update-policy.
- list_public_policies now surfaces is_recurring, renewal_period_days,
  trial_days so SDKs and the buy page can render cadence.

Admin UI (web/index.html)
- Create-policy form gets a "Recurring subscription (Pro)" section:
  is_recurring checkbox + cadence preset (monthly/quarterly/etc/custom)
  + grace period + trial days. Live enable/disable: the inputs gray
  out unless the box is ticked, and the custom-days input grays out
  unless "Custom" is selected.
- Edit-policy modal mirrors the same section, pre-populated from the
  policy's current values.
- Policies-list table shows a gold "every Nd" badge alongside the
  trial badge so operators can see at a glance which policies renew.

Buy page (/buy/<slug>)
- Tier cards on a recurring policy render a "Renews monthly/annually/
  every N days" meta line + a "/mo" / "/yr" / "/Nd" suffix on the
  price unit, so the headline reads "$25 / mo" not just "$25".
- First-cycle trial banner shows when trial_days > 0.
- TIERS JSON map exposes is_recurring + renewal_period_days +
  trial_days so the JS price-update path keeps the cadence suffix
  in sync when the buyer clicks between tiers.

Tests (+4, total now 53)
- recurring_policy_blocked_on_creator_tier — 402 + upgrade_url
- pro_tier_creates_monthly_recurring_policy — full create + verify
  via both admin GET and public list endpoint
- recurring_requires_positive_period — validator rejects period=0
- edit_policy_to_recurring_respects_tier_gate — Creator 402 on flip,
  Pro 200 on same flip, name-only PATCH on already-recurring policy
  doesn't re-fire the gate after downgrade

Drive-by: wrap the state-machine ASCII diagram in subscriptions.rs in
a ```text fence so cargo's doc-test runner stops trying to compile box
characters as Rust tokens.
2026-05-08 17:47:55 -05:00
Grant 7007bf8204 Recurring subs Phase 2 — renewal worker (committed, not published)
Implements the renewal lifecycle from RECURRING_SUBSCRIPTIONS_DESIGN.md
phase 2. Operators don't see this yet (no admin UI); the worker
only acts on subscriptions that exist in the schema, and creating
subscription rows still requires direct DB insert. Phase 4 (admin
UI) wires the buyer-facing surface that creates them.

src/subscriptions.rs (new module, ~450 LOC):
- find_due_renewals: subs with status active|past_due whose
  next_renewal_at has passed and consecutive_failures < cap
- find_lapsing_subscriptions: past_due subs whose
  (next_renewal_at + grace_period_days) is in the past
- mark_lapsed / mark_active_after_settle / mark_renewal_failed:
  state-transition helpers
- create_subscription: atomic create-sub + first-cycle invoice
  (called by purchase flow when policy.is_recurring; not yet
  wired — that's a separate phase)
- on_invoice_settled: helper for webhook handler to flip a sub
  from past_due back to active and dispatch subscription.renewed
- find_subscription_for_invoice: lookup helper
- tick: 60s sweep, picks up to 25 due renewals + lapse sweep
- spawn: long-lived background task, mirrors webhooks::spawn_delivery_worker

Renewal flow per due sub:
  1. Convert listed_value to sats via rates::convert_to_sats
     (identity for SAT subs; live rate fetcher for USD/EUR — per
     MULTI_CURRENCY_DESIGN.md "USD-stable / re-quote each cycle"
     decision).
  2. Get the active payment provider, call create_invoice with
     the same trait surface used by one-shot purchases. Works
     against BTCPay or Zaprite or any future provider.
  3. Persist the local invoice row carrying the rate audit
     (listed_currency / listed_value / exchange_rate_centibps /
     exchange_rate_source). For SAT subs, rate fields are NULL
     (identity conversion isn't worth recording).
  4. Insert subscription_invoices linking the invoice to the sub
     with monotonic cycle_number.
  5. Update sub: status → past_due, next_renewal_at → end of new
     cycle, last_renewal_attempt_at → now.
  6. Dispatch subscription.renewal_pending webhook to the operator.

On settle (webhook handler): if the invoice is linked via
subscription_invoices, flip sub → active, reset
consecutive_failures to 0, dispatch subscription.renewed.

Failure path: increment consecutive_failures, push next_renewal_at
out by exponential backoff (5min → 30min → 2h → 6h → 12h, capped
at 5 failures ≈ 24h of retries before the worker stops trying).
Operator can see stuck subs via the upcoming admin UI; for now
they show up in the audit log via webhook deliveries.

Lapse path: separate sweep finds past_due subs whose
(next_renewal_at + policy.grace_period_days) is past now, flips
to lapsed, dispatches subscription.lapsed.

Wired into:
- src/lib.rs: pub mod subscriptions
- src/main.rs: subscriptions::spawn(state.clone()) alongside
  reconcile + webhooks + analytics
- src/api/webhook.rs: settle path now calls
  subscriptions::on_invoice_settled before license issuance —
  ordering matters because first-cycle subs create both a sub
  row AND a license; we want the sub state correct on the way
  to the license-issuance branch

Test: 7 integration tests in tests/subscriptions.rs. Drives the
worker against a MockProvider with fail-on-demand semantics:
- renewal_worker_creates_invoice_for_sat_priced_due_sub: SAT sub
  charges listed_value sats verbatim, no rate audit, sub goes
  active → past_due, subscription_invoices gets a new cycle row
- renewal_worker_requotes_rate_for_fiat_priced_sub: $25 USD at
  pinned $50k/BTC = exactly 50,000 sats; rate audit pinned on
  invoice; centibps encoded correctly
- renewal_worker_backs_off_on_failure: failed create_invoice →
  consecutive_failures = 1, no invoice created, sub → past_due
- renewal_worker_stops_retrying_at_max_failures: pre-set failures
  = MAX, tick is a no-op for that sub
- lapse_sweep_flips_past_due_after_grace: 15-day-old past_due
  with grace=7 → lapsed
- settle_webhook_flips_sub_back_to_active: tick creates renewal,
  simulate settle, on_invoice_settled flips sub back to active
- tick_is_no_op_when_nothing_due: empty fixture, tick is safe

Test count: 49 (was 42; +7).

NOT bumping version. The recurring-subs feature isn't operator-
visible until phases 4+5 (admin UI for creating recurring
policies + buy page rendering for "$25/month"). Schema is in,
worker runs, but nothing creates subs yet — so this commit
ships dormant.
2026-05-08 17:26:10 -05:00
Grant 667db6ffd4 v0.2.0:3 release notes — durable provider switching
Bump with notes covering the active_payment_provider preference,
the new Activate <provider> actions, and the symmetric Disconnect
handling.

Test count: 42.
2026-05-08 16:51:47 -05:00
Grant ec2b21d8f7 v0.2.0:3 — durable payment-provider switching (Option B)
Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).

What changed:

**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.

**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.

**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
  successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
  successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
  provider being disconnected — but leave it alone if it pointed
  at the OTHER provider (different operator intent).

**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
  current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
  "zaprite" } — flips the active provider and the flag together,
  without going through the full Connect flow. 400 if the named
  provider isn't configured (operator must run Connect first).

**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.

**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.

Test count: 42 (was 41; +1).

Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.
2026-05-08 16:51:15 -05:00
Grant 0a76c9d121 v0.2.0:2 release notes — Zaprite + recurring subs schema
Bump to v0.2.0:2 with notes covering Zaprite as second payment
provider, migration 0011 (recurring subs schema dormant), 0012
(zaprite_config). Test count 41.
2026-05-08 16:35:40 -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 622fa77e29 v0.2.0:1 — drop FOUNDERS50 placeholder from buy-page discount input
Per operator feedback: the discount-code field on /buy/<slug> was
showing 'FOUNDERS50' as a placeholder, which confused buyers (some
tried it as a real code, some assumed Keysat shipped a default
discount). Empty placeholder now; buyers paste their actual code.

No semantic change. Wrapper-only revision; daemon binary unchanged
beyond the embedded HTML template.
2026-05-08 13:41:17 -05:00
Grant b45e84c3a2 v0.2.0:0 cutover — first non-alpha milestone
Swaps the version graph's current pointer from v0_1_0 to v0_2_0.
v0.1.0 stays in `other` so operators on the alpha line can upgrade
through the StartOS marketplace.

Per CUTTING_V0.2.0.md the steps are:
  1. swap versions/index.ts (this commit)
  2. npm run check (passed)
  3. make x86 (next)
  4. publish.sh (next)

What v0.2.0:0 represents — see the release notes in
startos/versions/v0.2.0.ts. Headlines: web admin SPA replaces
Actions for day-to-day work; multi-currency pricing functional
end-to-end; buyer self-service recovery; opt-in community
analytics; webhook delivery DLQ visible in dashboard;
PaymentProvider trait abstraction makes Zaprite drop-in for v0.3;
five-language SDK parity (daemon + Rust + TS + Python + Go).
2026-05-08 13:28:46 -05:00
Grant aeaab2d861 v0.1.0:51 — multi-currency complete + analytics UX polish
Bump with notes covering the SPA polish batch + edit-product currency
support. Last polish pass before v0.2.0:0 cutover.

Test count unchanged at 38. Straight drop-in upgrade from :50.
2026-05-08 13:23:00 -05:00
Grant 45e0cd2bd1 Edit-product currency support — operators can switch SAT ↔ USD/EUR in place
Closes the last multi-currency gap before v0.2.0:0 cutover. Operators
who created a product in one currency can now switch to another via
the Edit modal — no need to disable + recreate.

Backend:
- PATCH /v1/admin/products/:id accepts price_currency + price_value
  alongside the legacy price_sats. Same validation shape as the
  create endpoint (whitelist SAT|USD|EUR, mismatched legacy + typed
  → 400).
- repo::update_product_with_currency replaces the SAT-only
  update_product as the canonical entry; the SAT-only function is
  now a thin wrapper that always passes "SAT". For SAT updates,
  price_sats and price_value are dual-written. For fiat updates,
  price_sats is reset to 0 — gets repopulated by the rate fetcher
  on the next invoice creation against the product.

Frontend (Products → Edit modal):
- Currency picker dropdown next to the price input. Initial value
  reads from the product's current currency.
- For fiat products, the displayed price renders as decimal main
  units ($49.00); save converts to cents on the way out.
- Hint text + step swap as the operator changes currency.
- Doesn't auto-clobber the displayed value when currency changes
  — operator decides if the same number still makes sense.

No schema changes (column shape from migration 0010 is sufficient).

Test count unchanged at 38 — pure handler + UI work, behavior
covered by the existing currency tests on create.
2026-05-08 13:22:00 -05:00
Grant 0dcae66e05 SPA polish — compact analytics opt-in, discount-code currency picker, fiat tier rendering
Analytics opt-in (Overview page):
- Replaces the prominent "Help improve Keysat" card with a compact
  one-line strip below the public-key card. Single sentence + native
  checkbox + "what gets sent?" link that toggles an inline disclosure.
- Auto-saves on toggle (no separate Save button) so the affordance
  reads as "click it and it's done", not as a multi-step form.
- Default remains OFF — the right call for Keysat specifically given
  the product positioning around sovereignty / no phone-home.
- Inverted-checkbox UX bug fixed (was rendering "☑ Disabled" which
  reads as a double-negative and confused operators).
- Reset install_uuid moves into the expanded view as a small "reset"
  link rather than a prominent button.

Discount-code create form:
- New Currency picker dropdown next to Amount (SAT default, USD,
  EUR). For 'percent' the currency is recorded for audit but
  amount remains basis points; for 'fixed_sats' / 'set_price'
  the currency determines the unit (sats for SAT-currency,
  cents for USD/EUR).
- Decimal entry on USD/EUR ($9.99) converts to cents on the way out.
- Hint text + step attribute swap live as the operator changes
  Kind or Currency.
- Discount-code list cell now formats fiat amounts as "$10.00 off"
  / "€25.00 flat" with cents-to-main-unit conversion. Existing
  SAT codes render unchanged.

Buy page tier picker (JS + server render):
- Tier cards' static HTML now respects product.price_currency:
  USD products render as "49.00 USD" instead of "0 sats" (which
  was happening for fiat-priced products since price_sats=0 for
  those).
- TIERS JSON embedded in the page now carries (price_currency,
  price_value) alongside the legacy price_sats. JS selectTier()
  reads the right fields and swaps the unit cell ("sats" ↔ "USD")
  in addition to the amount when the buyer clicks a different tier.
- formatTierPrice() helper centralizes the SAT-vs-fiat rendering;
  free-tier detection checks the value in the relevant unit.

build_tiers_json() also wired to pass currency through. Per-policy
currency override stays NULL = "inherit from product" until v0.3
admin UI lands.

Test count unchanged at 38 (this is purely SPA + buy-page render
work; behaviour is covered by existing API tests).
2026-05-08 13:19:41 -05:00
Grant ec75919d72 v0.1.0:50 release notes — hotfix for migration checksum crash-loop
Drop-in upgrade for operators stuck on :49 crash-loop. No data loss.
2026-05-08 13:00:35 -05:00
Grant 9919fbf8f8 v0.1.0:50 — auto-recover from sqlx checksum drift on idempotent migrations
Two operators in a row hit the same crash-loop on upgrade:

  Error: running migrations
  Caused by:
      migration 9 was previously applied but has been modified

sqlx records a SHA-384 of each migration's bytes when first applied,
then verifies the on-disk bytes still match on every subsequent boot.
Cross-build drift (trailing newlines, line-ending normalization, etc.)
produces different bytes for semantically-identical SQL — and sqlx
refuses to start. Recovery required SSHing in and running:

  sqlite3 /data/keysat.db "DELETE FROM _sqlx_migrations WHERE version = 9;"

That's bad UX. Worse, every operator going through this version
range hits it once.

Self-heal: db::init now wraps sqlx::migrate!().run() with detection
for MigrateError::VersionMismatch(N) on a constant allowlist of
migrations certified safe to re-run (IDEMPOTENT_MIGRATIONS, just [9]
for now). When triggered, the daemon clears the stale row, retries,
logs a WARN explaining what happened, and continues. No SSH dance.

Allowlist gate is critical — auto-clearing checksums on additive
ALTER TABLE migrations like 0010 would error on retry (SQLite has
no ADD COLUMN IF NOT EXISTS). Only migrations explicitly designed
as drop-and-rebuild (like 0009) and tested via the
`migration_NNNN_is_idempotent` pattern in tests/migrations.rs
qualify.

Regression test in tests/migrations.rs exactly simulates the
production incident:
  1. apply all migrations cleanly
  2. poison v9's recorded checksum with bogus bytes
  3. confirm raw sqlx::migrate! bails (proves the poisoning works)
  4. call db::init — must succeed by clearing + re-applying v9
  5. confirm v9 + v10 are both recorded with non-poisoned checksums

Test count: 38 (was 37; +1 db_init_self_heals test).

For operators currently stuck on the :49 crash-loop: just upgrade
to :50 from the StartOS marketplace. The :50 daemon will see the
mismatch on first boot, auto-clear v9's row, re-apply (0009 is
idempotent by design), and continue to 0010. No manual sqlite3 needed.
2026-05-08 13:00:06 -05:00
Grant 29be2405a8 v0.1.0:49 — multi-currency pricing functional end-to-end
Bump version with release notes covering Phases 2-6 of the multi-
currency design (admin UI write path, buy page fiat rendering, rate
fetcher, invoice rate recording, currency-aware discount codes).
Operators can list products in USD/EUR and accept BTC; the daemon
converts at invoice creation and pins the rate.

Test count: 37. Straight drop-in upgrade from :48.
2026-05-08 12:22:14 -05:00
Grant d8aa9c22b9 Multi-currency Phases 3, 5, 6 — buy page, invoice rate recording, discount currency
Phase 5 (invoice records the rate):
- repo::create_invoice_with_currency takes the listed currency,
  listed value, exchange_rate_centibps, and exchange_rate_source as
  optional params; create_invoice (the legacy form) becomes a thin
  wrapper that passes None for all four. SAT-priced flows are
  unchanged.
- purchase::start now branches on product.price_currency: SAT keeps
  the existing path; USD/EUR calls rates::convert_to_sats and pins
  the listed price + rate to the local invoice row for audit. The
  buyer is still billed in BTC (BTCPay invoice is sat-denominated)
  but the audit trail records what they SAW vs what they were
  charged.
- Test paid_purchase_in_usd_records_listed_currency_and_rate seeds
  a manual rate pin ($50k/BTC), creates a USD-priced product
  ($49.00), runs through purchase, asserts the invoice row carries
  listed_currency='USD', listed_value=4900, rate_centibps=
  500_000_000, source='manual_pin', amount_sats=98_000.

Phase 3 (buy page renders fiat):
- Server-rendered initial price respects product.price_currency:
  USD products show "49.00 USD" (cents converted to display dollars)
  instead of sats. Tier-picker JS still formats per-tier prices in
  sats — that's a v0.3 polish when we plumb the rate into the JS
  render path. Most operators ship single-policy products at first,
  so the static initial render is the high-leverage piece.

Phase 6 (currency-aware discount codes):
- POST /v1/admin/discount-codes accepts optional `discount_currency`
  field ('SAT' default, 'USD', 'EUR'). Whitelisted in the handler.
- repo::create_discount_code is now a thin wrapper around
  create_discount_code_with_currency; the new helper persists
  discount_currency to the column added in 0010. Existing SAT-only
  codes keep working unchanged.

Test count: 37 (was 36; +1 paid_purchase_in_usd test).

Multi-currency design phases 1-6 all shipped (1: schema in :48; 2:
admin UI write in :48-:49; 3: buy page; 4: rate fetcher; 5: invoice
audit; 6: discount currency). Phase 7 (recurring subscriptions
re-quote) is v0.3 territory — needs the recurring-billing scaffolding
from Zaprite first.
2026-05-08 12:21:26 -05:00
Grant eb885502ba Multi-currency Phase 4 — rate fetcher with Kraken/Coinbase/CoinGecko fallback
src/rates.rs adds an in-memory rate cache (60s TTL) with a 3-source
fallback chain. AppState gains `rates: Arc<RateCache>`. Manual pins
via the settings table override the chain — used by tests for
deterministic conversions and by operators during maintenance
windows.

Admin endpoints:
- GET /v1/admin/rates: cache snapshot
- POST /v1/admin/rates/refresh: force re-fetch (audit-logged)

Two new tests (network-free, manual-pin path):
- rate_cache_honors_manual_pin_from_settings
- admin_rates_endpoint_reflects_manual_pin

Test count: 36 (was 34).
2026-05-08 12:16:22 -05:00
Grant 356d17fdde Multi-currency Phase 2 — admin write path (currency picker)
Backend:
- POST /v1/admin/products accepts both forms:
  - legacy: { price_sats: 50000 }
  - typed:  { price_currency: 'USD', price_value: 4900 }
  Whitelist enforced (SAT|USD|EUR). Mismatched legacy + typed → 400
  to catch half-migrated clients sending stale price_sats alongside
  fresh price_value.
- repo::create_product_with_currency: SAT → dual-write price_sats =
  price_value; USD/EUR → price_sats = 0 until first invoice creation
  triggers a rate lookup (Phase 4 + 5).
- Test admin_create_product_accepts_legacy_and_typed_currency_forms
  pins 6 happy/sad paths.

Frontend (Products page):
- Create-product form has a currency picker (sats / USD / EUR).
  Picker swaps the unit hint + step in place.
- Decimal entry on USD/EUR is converted to cents on the way out.
- Products table renders prices via formatProductPrice(): USD
  products show "$49.00" with optional "≈ 75k sats" hint.

Test count: 34 (was 33).
2026-05-08 12:11:36 -05:00
Grant 201c081009 v0.1.0:48 — multi-currency schema foundation
Bump version with release notes for migration 0010 (additive multi-
currency columns + backfill) and the model/repo updates wiring
the new fields into the read/write paths.

Test count: 33. Straight drop-in upgrade — no admin action,
backfill runs automatically in the migration transaction.
2026-05-08 12:01:01 -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 7ce30008ff v0.1.0:47 — opt-in community analytics + v0.2.0:0 plumbing parked
Bumps version with release notes covering:
- Community analytics opt-in (admin Overview surface, off by default,
  full privacy disclosure including a live preview of the exact
  JSON heartbeat that would be sent)
- Floor-to-5 anti-fingerprinting on counts pinned by test
- Draft v0.2.0:0 release notes parked at startos/versions/v0.2.0.ts
- CUTTING_V0.2.0.md cutover guide

Test count: 32. Straight drop-in upgrade from :46.
2026-05-08 11:42:28 -05:00
Grant 02f80b04eb v0.2.0:0 plumbing prep — draft version file + cutover doc
Adds startos/versions/v0.2.0.ts as a draft milestone version entry,
ready to swap in as `current` when we're ready to cut. NOT yet wired
into the version graph at versions/index.ts — flipping that switch
is a release decision (one-line change there, then make x86 +
publish), and the draft sits parked so we can iterate on the
release-notes content without committing to the cut.

Format note: the SDK's VersionInfo.of() expects releaseNotes as a
LocaleString (Record<string, string>), not the string[] form
v0.1.0.ts uses. The new file uses the modern shape; v0.1.0.ts keeps
its existing form to avoid churn on the alpha line.

CUTTING_V0.2.0.md walks the operator (or future me) through the
4-step cutover: edit versions/index.ts to swap in v0_2_0, npm run
check, make x86, publish. Plus rollback notes if anything goes
sideways post-cut.

Why park rather than cut now:
1. The user said "prepare for the version 0.2 plumbing" — that's
   "prepare" not "do". The cutover is intentional in the user's
   workflow, not bundled into a routine push.
2. Cutover changes how the StartOS marketplace renders the upgrade
   dialog to existing :N installs; best to QA the release-notes
   content first.
3. SDK migration-API behavior on the upstream version bump is
   worth verifying on a test install before flipping for everyone.

The v0.2.0 release notes themselves are written conservatively —
they describe what's already shipped and stable in the alpha line
through :47, not aspirational v0.3 features.
2026-05-08 11:41:55 -05:00
Grant d827b1aaab Opt-in community analytics + admin UI surface
Closes the last T2 plan item. Off by default; toggling on requires
the operator to confirm a collector URL (an empty URL is "armed but
silent"). The toggle lives on the admin Overview page next to the
public-key card — the right place for a privacy-affecting choice
since it's where operators actually live.

What's sent (per the in-card "Show me exactly what gets sent"
disclosure, and pinned by the test):
- install_uuid: random UUIDv4 generated on first opt-in. NOT
  derived from operator_name, store id, public URL, or any
  other identifier. Wipeable via the Reset button.
- daemon_version (CARGO_PKG_VERSION).
- tier (creator/pro/patron/unlicensed) — the same string the
  admin tier endpoint already exposes.
- counts: products, active_licenses, settled_invoices — each
  floored to the nearest 5 (anti-fingerprinting; an exact license
  count uniquely identifies an operator over time).
- uptime_bucket: <1d / 1-7d / 1-4w / >4w (bucketed, not exact).

What's NOT sent (test asserts none of these strings appear in the
preview heartbeat): operator_name, public_url, store_id, api_key,
buyer_email, btcpay_url. Also no product/policy slugs or names, no
license/invoice ids, no fingerprints, no webhook secrets.

Backend:
- src/analytics.rs — heartbeat builder, opt-in check, daily
  background tick (5min initial grace period after boot).
- src/api/community.rs — GET / POST / reset admin endpoints.
- main.rs spawns the background tick unconditionally; the tick
  is a no-op if disabled OR no collector URL configured.

Frontend (web/index.html, Overview page):
- Toggle + collector URL input + privacy disclosure showing the
  EXACT JSON shape that would be sent (renders the live preview
  heartbeat from /v1/admin/community-analytics).
- "Reset install_uuid" button so an operator who's been beaconing
  under one identifier can start fresh.

Also includes the configureBtcpay.ts idempotency change from
v0.1.0:46 (already committed; touched again here only because the
diff includes the .ts file in the same dirty-tree push).

Test count: 32 (was 31; +1 community_analytics_opt_in_and_privacy_contract
which seeds 23 licenses and verifies the heartbeat reports 20 —
proves the floor-to-5 anti-fingerprinting is in effect).
2026-05-08 11:35:50 -05:00
Grant 763a44bbdd v0.1.0:46 — idempotent Connect BTCPay, Go SDK now part of toolchain
Closes the last T1 BTCPay UX gap from V0.2_PLAN. Connect now checks
/v1/admin/btcpay/status first; if a connection exists, returns a
clear "already connected" guidance message pointing the operator at
Disconnect → Connect for re-authorize cases. Without this guard,
re-clicking Connect spawned a new webhook subscription on BTCPay's
side every time, leaving orphan webhooks BTCPay would keep trying
to deliver to.

The Go SDK has been written and verified — all 4 crosscheck tests
pass against the shared tests/crosscheck/vector.json (the same file
the Rust/TS/Python SDKs and the daemon test against). Pure stdlib,
zero third-party dependencies. Hosted in its own repo at
github.com/keysat-xyz/keysat-client-go (private during alpha).

This release IS the 5th-language milestone: daemon + Rust + TS +
Python + Go all agree byte-for-byte on the LIC1 wire format.

Daemon binary unchanged — wrapper-only revision.
2026-05-08 11:20:17 -05:00
Grant 9c5be85c55 v0.1.0:45 — buyer self-service recovery + db-info endpoint
Bump version with release notes covering the two operator-facing
additions in f6ba1c1:
- POST /v1/recover (+ GET /recover HTML form) for buyer self-service
- GET /v1/admin/db-info for db health snapshot

Test count: 31 (was 30). Straight drop-in upgrade from :44.
2026-05-08 11:06:16 -05:00
Grant f6ba1c160e Buyer self-service recovery + db-info admin endpoint
Two operator-facing additions, both addressing risks we'd flagged
earlier in the v0.2 plan but hadn't shipped.

**POST /v1/recover (+ GET /recover HTML form).** Lets a buyer who
lost their license key re-derive it themselves by presenting their
invoice id + the email they paid with. Until now, the recovery
flow was "DM the operator with your invoice id and they re-send" —
operator-time scaling badly. With this, the buyer self-serves and
the operator never has to know.

The endpoint takes (invoice_id, email), case-insensitive on email.
Returns a generic 404 on any mismatch — does NOT distinguish
"invoice not found" from "wrong email" so an attacker can't
brute-force email addresses against a known invoice id. Per-IP
rate limited at 10 requests / minute. Audit-logged as
license.recovered with the email's SHA-256 hash so PII isn't
written to the log.

The HTML form at GET /recover is server-rendered, no JS framework,
no cookies — designed for a customer who's just had a catastrophic
failure of their primary computer and reached us from whatever
device they could find.

Test in tests/api.rs:recover_returns_license_key_for_matching_pair
exercises the happy path (case-insensitive email match), the
generic-404 paths (wrong email, missing invoice), the round-trip
(recovered key validates via /v1/validate), and the audit-log
write.

**GET /v1/admin/db-info.** Cheap insurance against the
catastrophic-loss risk: /data/keysat.db is a single SQLite file,
losing it invalidates every license ever issued. StartOS's backup
machinery handles snapshotting; this endpoint gives operators a
sanity-check surface they didn't have before:
  - DB file path + on-disk size
  - last-write timestamp (max across audit_log, invoices, licenses)
  - row counts for products, policies, licenses (total + active),
    invoices (total + settled), machines (active), discount codes,
    audit log entries

Doesn't report when StartOS last backed it up — the daemon has no
visibility into the host's snapshot subsystem. What it gives the
operator is a "I expected ~50 licenses and I see ~50 licenses; the
file is N MB; the last write was 6 hours ago" check.

Test count: 31 (was 30; +1 for the recover test).
2026-05-08 11:05:10 -05:00
Grant a7ea47fd63 v0.1.0:44 — DLQ in dashboard, trait migration completes, worker + crosscheck tests
Bumps version with release notes covering everything since :43:
- Webhook DLQ visible in admin SPA with one-click retry
- reconcile.rs + tipping.rs migrated onto PaymentProvider trait
  (production refactor; daemon's non-test code now contains zero
   calls to the BTCPay-specific compat accessors)
- 3 worker integration tests pin the retry/dead-letter behavior
  empirically against real HTTP receivers
- 4 daemon-side crosscheck tests pin the wire-format parser
  against the same vector.json the SDKs use independently

Test count: 30 (was 23). Straight drop-in upgrade from :43.
2026-05-08 10:44:46 -05:00
Grant 655e0d51f8 Daemon-side wire-format crosscheck
Loads tests/crosscheck/vector.json (the same file the TS, Python, and
Rust SDKs each test against independently) and verifies the daemon's
crypto::parse_key produces field-by-field identical values.

What was missing: the SDKs each ran their crosscheck against the
shared vectors, but the **daemon itself** never did. The daemon
shares no parser code with the SDKs (separate trees, separate
implementations of the same byte layout), so drift in the daemon's
parser could ship undetected until an SDK on the wire couldn't
validate a daemon-issued key.

Four tests, one per fixture in vector.json (v1 legacy fingerprint-
bound, v2 trial with entitlements, v2 perpetual unbound), plus a
sanity check that publicKeyPem is present.

Each fixture asserts: version, product_id UUID, license_id UUID,
issued_at, expires_at, flags + derived `is_fingerprint_bound`/
`is_trial` getters, entitlements (order-sensitive), and the 32-byte
fingerprint_hash bytes hex-encoded. When `fingerprintRaw` is
provided and binding is active, hashes the raw fingerprint with
crypto::hash_fingerprint and asserts the result matches the wire
bytes — pinning the SHA-256 contract the SDKs depend on.

Signature verification is intentionally out of scope: the unit
tests in src/crypto/mod.rs already prove daemon's sign/verify
roundtrip works, and the SDKs prove the same key verifies in three
independent crypto implementations. The parser-to-fields contract
is what hadn't been pinned from the daemon's side, and what this
file enforces.

Test count: 30 (9 unit + 4 migration + 10 API + 3 worker + 4
crosscheck), up from 26.
2026-05-08 10:43:36 -05:00
Grant 4adf5a8593 Admin SPA: surface webhook delivery history (DLQ visible)
The /v1/admin/webhook-deliveries endpoints from v0.1.0:43 were
operator-actionable via curl but invisible in the dashboard. Adds a
"Delivery history" section to the Webhooks page showing recent
deliveries with a status filter (defaults to "Failed (DLQ)" so the
problem case is what an operator sees first).

Each row shows created-at, event type, status badge (delivered /
failed / pending), attempt count, last status code, and last_error
inline beneath the status when present (so operators don't have to
chase a separate "details" view to know why a delivery failed).
Non-delivered rows get a Retry button that re-queues via the
existing POST /v1/admin/webhook-deliveries/:id/retry; the worker
picks up the retried row on its next 5s tick.

No backend changes. The endpoints landed in :43; this commit is
just the front-end surface.
2026-05-08 10:41:44 -05:00
Grant 5ec9a6e8c0 Migrate reconcile + tipping onto PaymentProvider trait; add worker tests
Two compat-path holdovers migrated:

- src/reconcile.rs: was state.btcpay_client().get_invoice() with
  manual JSON parsing of BTCPay-specific status strings ("Settled",
  "Complete", "Expired", "Invalid"). Now state.payment_provider()
  .get_invoice_status() returning the typed ProviderInvoiceStatus
  enum. The string normalization moves into BtcpayProvider's impl
  where it belongs.

- src/tipping.rs: was state.btcpay_client().pay_lightning_invoice()
  returning raw JSON, then manual paymentHash extraction. Now
  provider.pay_lightning_invoice() returning a typed PaymentReceipt
  { payment_hash, raw }. The audit message now records the active
  provider's kind() rather than hardcoding "BTCPay LN node".

Combined with v0.1.0:43's purchase migration, the daemon's
non-test code now contains zero calls to state.btcpay_client() or
.btcpay_webhook_secret(). Those compat accessors stay on AppState
for v0.2 (no need to break things gratuitously) but they're dead
code in the production path. Zaprite's drop-in only needs to
implement the trait.

Worker integration tests (tests/worker.rs):

- worker_marks_failure_and_schedules_retry_on_500: spins up a tiny
  axum receiver that 500s, calls webhooks::tick(), verifies attempt
  count and next-attempt scheduling.
- worker_dead_letters_after_max_attempts: seeds a row at attempt
  count 9, ticks once, verifies attempt_count → 10 and
  next_attempt_at → NULL. Confirms the row also satisfies the admin
  DLQ predicate (the contract :43's webhook_deliveries.rs depends
  on).
- worker_marks_success_on_2xx: pins the happy path.

webhooks::tick is now `pub` so integration tests can drive it
synchronously.

Test count: 26 (9 unit + 4 migration + 10 API + 3 worker).
2026-05-08 10:40:11 -05:00
Grant 96490bf3bf v0.1.0:43 — webhook DLQ, purchase trait migration, three more tests
Bumps version with release notes covering everything since v0.1.0:42:
- Webhook DLQ: list + retry admin endpoints (operator-visible)
- Purchase migrated onto PaymentProvider trait (internal refactor)
- Tier-cap test, paid-purchase test, DLQ test
- Test count 20 → 23

Straight drop-in upgrade from :42. No migrations, no schema changes.
2026-05-08 09:39:43 -05:00
Grant f9ef1a854c Webhook DLQ — list failed deliveries and manually retry
Closes the silent-loss hole in outbound webhook delivery. The worker
in src/webhooks.rs retries failed deliveries with exponential backoff
up to 10 attempts, then sets next_attempt_at = NULL and walks away.
Pre-this-commit, those "dead-lettered" rows sat in webhook_deliveries
forever with no surface for the operator to discover, inspect, or
recover from them — a subscriber that was down for >6h during a
license-issuance burst would silently lose those events forever.

What's new:

- repo::DeliveryStatusFilter — enum with parse() so query strings
  map cleanly to SQL predicates.
- repo::list_deliveries — endpoint_id + status + limit, newest first.
- repo::requeue_delivery — resets attempt_count=0, clears delivered_at
  and last_error, sets next_attempt_at=now. The worker picks it up on
  the next 5s tick.

- src/api/webhook_deliveries.rs — admin module with two handlers:
  - GET /v1/admin/webhook-deliveries?endpoint_id=…&status=…&limit=…
  - POST /v1/admin/webhook-deliveries/:id/retry  (audit-logged as
    webhook_delivery.retry; 404 on missing id)
- Routes registered in src/api/mod.rs alongside the existing
  webhook_endpoints CRUD.

- tests/api.rs gains webhook_dlq_lists_failed_and_retry_requeues:
  seeds three deliveries directly via SQL (one each: delivered,
  pending, dead-lettered), exercises the list filter, runs the retry,
  asserts the row migrates from failed→pending, audit row is written,
  404 on bad id, 400 on bad status filter.

Worker code is unchanged. The DLQ is operator-actionable infrastructure
on top of the existing retry semantics.

Test count: 23 (9 unit + 4 migration + 10 API), up from 22.
2026-05-08 09:38:58 -05:00
Grant e2b296ce29 Migrate purchase::start onto PaymentProvider trait + paid-purchase test
Drops the legacy compat path. `purchase::start` now calls
`state.payment_provider().await?.create_invoice(CreateInvoiceParams {
...})` instead of `state.btcpay_client().await?.create_invoice(...)`.
Provider-specific concerns (BTCPay's checkout-URL rewriting from the
internal Docker hostname to the public domain, metadata enrichment
with `orderId` / `source`) move inside the BtcpayProvider impl where
they belong; the same code path now serves any future provider
(Zaprite, etc.) without fork/copy.

URL rewriting is removed from the caller (no longer needs to know
which provider's URLs to rewrite or how). The
`crate::payment::btcpay::rewrite_to_public` function stays on the
provider impl; pubpath unchanged.

Adds `paid_purchase_creates_invoice_via_provider` integration test —
previously deferred per :42's release notes because the compat path
prevented MockPaymentProvider from substituting. Now the mock works
through the same call site as production. Verifies:
  - daemon delegates invoice creation to the provider
  - returned provider_invoice_id is stamped on the local invoice row
  - checkout_url is what the provider returned
  - no license issued at this stage (that's the webhook's job)

Test count: 22 (9 unit + 4 migration + 9 API).
2026-05-08 09:35:41 -05:00
Grant 34704bfa03 Add tier-cap enforcement test
Verifies the 402 PAYMENT_REQUIRED gate on /v1/admin/products fires at
the Creator-tier product cap (5), and that swapping `self_tier` to a
Licensed tier with `unlimited_products` lifts the cap without a
daemon restart. Mirrors what the admin UI's "Activate Keysat license"
flow does at runtime.

Validates two production-correctness invariants:
  - the 402 carries an `upgrade_url` so the SPA can render the
    upgrade CTA inline (rather than a generic error)
  - the failed attempt does NOT leak a row into the products table —
    the cap fires BEFORE the insert
2026-05-08 09:32:53 -05:00
Grant c11764898b v0.1.0:42 — webhook idempotency test + free-purchase test
Two new API integration tests, both targeting production-correctness
invariants worth locking down:

- free_purchase_issues_license_inline: exercises the price=0 shortcut
  (price_sats_override=0 on a "free" tier policy). Verifies the daemon
  synthesizes a settled invoice locally, issues a license inline, and
  the inlined license_key validates round-trip via /v1/validate.

- webhook_settles_invoice_and_issues_license_idempotently: the most
  important new test in this set. A pending invoice + an InvoiceSettled
  webhook → license issued, status flipped. Re-delivering the SAME
  webhook (which providers DO retry, sometimes aggressively) must NOT
  duplicate the license. A duplicated license here means duplicated
  revenue and duplicated revocation surface area — both bad. This test
  pins the invariant.

MockPaymentProvider added to tests/api.rs: a test-only PaymentProvider
impl that bypasses HMAC verification and parses test-supplied JSON
bodies into ProviderWebhookEvent variants. Lets us drive deterministic
settle/expire/invalid events without a real BTCPay roundtrip. Never
compiled into the production binary.

Paid-purchase test deferred: purchase::start still uses the legacy
state.btcpay_client() compat accessor that downcasts to the concrete
BtcpayProvider, which the mock can't satisfy. Documented inline. Slots
in trivially after the trait migration on the v0.3 backlog.

Version bump to v0.1.0:42 with release notes covering everything since
:41 was published: lib.rs library refactor, the original 5 API tests
from 81066df, the 2 new ones above, KEYSAT_INTEGRATION.md restoration.
No daemon-behaviour changes for operators; straight drop-in upgrade
from :41.

Test count: 20 (9 unit + 4 migration + 7 API), up from 13 in :41.
2026-05-08 09:24:57 -05:00
Grant 81066dfe62 Add API endpoint integration tests + library scaffolding
Closes the next-biggest test gap after migration tests. The daemon has
54+ HTTP endpoints, all previously untested at the request/response
level — same shape of blind spot that allowed the v0.1.0:39 migration
bug to ship.

What's new:

- src/lib.rs — exposes the daemon's modules as a library so integration
  tests can import them (`pub mod api;`, etc.). Module source files are
  unchanged; main.rs now imports via `use keysat::...` instead of
  declaring `mod api;` directly. No runtime behaviour change in the
  binary.

- tests/api.rs — 5 integration tests that drive real HTTP requests
  through axum::Router::oneshot against a real SQLite tempfile pool
  (same options as src/db/mod.rs::init):
    1. health_endpoint_returns_200 — framework smoke test
    2. admin_endpoint_rejects_missing_or_wrong_auth — 401 vs 403 paths
    3. admin_creates_product_with_correct_token — full happy path
       (auth → handler → DB insert → audit log → response)
    4. validate_rejects_unsigned_garbage — early parse-fail surfaces
       as `ok: false, reason: "bad_format"` (HTTP still 200)
    5. validate_accepts_well_formed_license — issues a license via
       repo, signs a matching LicensePayload with the daemon's
       actual key, encodes to wire format, validates via the
       endpoint, asserts ok=true plus populated metadata fields

Test count: 9 unit + 4 migrations + 5 API = 18 (was 13).

Cargo.toml dev-deps gain `tower = { version = "0.4", features = ["util"] }`
for ServiceExt::oneshot. The main `tower` dep is feature-minimal because
axum only needs a subset.

Out of scope (explicit follow-ups):

- Purchase happy path (needs a MockPaymentProvider implementing the
  trait; ~250 LOC of mock + ~200 LOC of test).
- Webhook handler with idempotency assertions (same MockPaymentProvider
  dependency).
- Tier-cap enforcement (mechanically simple; small follow-up PR).
- Discount-code atomic reserve race (better as a SQL-layer unit test
  than an HTTP integration test).
- Rate-limiting (interacts with shared state; needs careful isolation).
- Cookie/session auth (already covered in session_layer.rs).
2026-05-08 09:14:27 -05:00
Grant 4ac856bb10 Restore KEYSAT_INTEGRATION.md (mistakenly deleted in v0.1.0:41)
The previous commit removed the canonical 1378-line integration guide
based on a misread of intent — the file's "moved to startos folder"
note referred to *this* (licensing-service-startos) repo. The 12-line
stub at the parent licensing/ folder is the forwarder, not the canonical.

No version bump: doc-only restore, no on-disk or daemon behaviour
change. v0.1.0:41 release notes contain an incidental line stating
"KEYSAT_INTEGRATION.md is removed from this repo" — left as-is for
now since the .s9pk hasn't been re-published since :41. If we
re-publish :41 and the line bothers us, a separate commit can correct
it before the next .s9pk build.
2026-05-08 08:17:33 -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