Commit Graph

5 Commits

Author SHA1 Message Date
Grant 4251e96082 Migration 0011 — recurring subscriptions schema (committed, not published)
Foundation-only commit. Adds the storage shape for recurring-billing
licenses; daemon code that uses these tables (renewal worker,
validate-hot-path subscription branch, admin endpoints, buy-page
recurring rendering) lands in subsequent commits.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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