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.
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.