Commit Graph

9 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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