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.
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
-- Recurring subscriptions: schema foundation.
|
||||||
|
--
|
||||||
|
-- This migration adds the storage shape needed for recurring-billing
|
||||||
|
-- licenses. Daemon code that USES these tables lands in subsequent
|
||||||
|
-- commits (renewal worker, validate-hot-path subscription branch,
|
||||||
|
-- admin endpoints, buy-page recurring rendering). Per the
|
||||||
|
-- RECURRING_SUBSCRIPTIONS_DESIGN.md doc at the repo root.
|
||||||
|
--
|
||||||
|
-- Strategy: additive only. Existing one-shot purchase flows are
|
||||||
|
-- untouched. A license becomes "subscription-backed" when its
|
||||||
|
-- `licenses` row gets a corresponding `subscriptions` row (1:1
|
||||||
|
-- via `subscriptions.license_id UNIQUE`). One-shot purchases just
|
||||||
|
-- never get a subscriptions row, behave exactly as before.
|
||||||
|
--
|
||||||
|
-- Decisions encoded here that depend on Grant's design-doc
|
||||||
|
-- answers (placeholders are best-guess defaults; can be tuned via
|
||||||
|
-- ALTER COLUMN DEFAULT in a follow-up if the answers differ):
|
||||||
|
-- - grace_period_days default: 7
|
||||||
|
-- - trial_days column on policies: included from day 1
|
||||||
|
-- (cheaper to ship now than to migrate later)
|
||||||
|
-- - cancellation refund: NOT a schema concern (no refund column;
|
||||||
|
-- mid-cycle cancellation just stops next charge, license
|
||||||
|
-- stays valid through current cycle, no DB change)
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- policies: recurring + trial flags
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- A policy with `is_recurring = 1` issues subscription-backed
|
||||||
|
-- licenses. `renewal_period_days` is required when is_recurring=1
|
||||||
|
-- (CHECK enforced); NULL otherwise. `grace_period_days` applies
|
||||||
|
-- only to recurring policies.
|
||||||
|
--
|
||||||
|
-- `trial_days` is independent of is_recurring — even one-shot
|
||||||
|
-- products can have a "free 14 days, then key revokes" trial flow,
|
||||||
|
-- though most operators will use trials only on recurring policies.
|
||||||
|
ALTER TABLE policies ADD COLUMN is_recurring INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE policies ADD COLUMN renewal_period_days INTEGER;
|
||||||
|
ALTER TABLE policies ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 7;
|
||||||
|
ALTER TABLE policies ADD COLUMN trial_days INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Helps the renewal worker filter policies cheaply.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_policies_recurring ON policies(is_recurring);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- subscriptions: one row per subscription-backed license
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- The license row remains the source of truth for entitlements +
|
||||||
|
-- product/policy linkage. The subscription row tracks the cycle
|
||||||
|
-- state machine that determines whether the license is currently
|
||||||
|
-- valid (active / past_due-with-grace) or not (lapsed / cancelled).
|
||||||
|
--
|
||||||
|
-- Pricing snapshot fields (listed_currency / listed_value /
|
||||||
|
-- period_days) are frozen at subscription creation. Operators
|
||||||
|
-- changing the underlying policy's price doesn't affect existing
|
||||||
|
-- subscriptions; the next renewal still bills the snapshotted
|
||||||
|
-- amount. To migrate existing subscribers to a new price, the
|
||||||
|
-- operator either re-issues the license at the new policy
|
||||||
|
-- (admin action) or waits for natural cancellation + repurchase.
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4
|
||||||
|
license_id TEXT NOT NULL UNIQUE, -- 1:1 with licenses
|
||||||
|
policy_id TEXT NOT NULL, -- denormalized; renewal worker reads it
|
||||||
|
product_id TEXT NOT NULL, -- denormalized for cheap admin filters
|
||||||
|
|
||||||
|
-- Cycle schedule. Frozen at subscription creation.
|
||||||
|
period_days INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- Pricing snapshot. Frozen at subscription creation; see comment above.
|
||||||
|
-- For SAT-currency subs: same value charged each cycle (no rate fluctuation).
|
||||||
|
-- For USD/EUR subs: listed_value is stable in the listed currency, BUT
|
||||||
|
-- the BTC amount drifts each cycle as the rate fetcher re-quotes
|
||||||
|
-- (this is the USD-stable / re-quote-each-cycle decision from
|
||||||
|
-- MULTI_CURRENCY_DESIGN.md).
|
||||||
|
listed_currency TEXT NOT NULL,
|
||||||
|
listed_value INTEGER NOT NULL, -- smallest unit of listed_currency
|
||||||
|
|
||||||
|
-- Lifecycle state machine. See RECURRING_SUBSCRIPTIONS_DESIGN.md
|
||||||
|
-- for the full diagram. CHECK enforces only valid values can ever
|
||||||
|
-- land in the column.
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL, -- ISO-8601 UTC
|
||||||
|
next_renewal_at TEXT, -- when the worker creates the next invoice; NULL once cancelled-and-past-cycle
|
||||||
|
cancelled_at TEXT,
|
||||||
|
cancellation_reason TEXT,
|
||||||
|
|
||||||
|
-- Audit / dunning state. consecutive_failures backs off the renewal
|
||||||
|
-- worker on repeated failure (see RECURRING_SUBSCRIPTIONS_DESIGN.md
|
||||||
|
-- §"Renewal worker"). last_renewal_attempt_at lets the admin UI
|
||||||
|
-- show "we tried 3 hours ago, will retry in 9 hours."
|
||||||
|
last_renewal_attempt_at TEXT,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||||
|
FOREIGN KEY (policy_id) REFERENCES policies(id),
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||||
|
CHECK (status IN ('active', 'past_due', 'cancelled', 'lapsed')),
|
||||||
|
CHECK (period_days > 0),
|
||||||
|
CHECK (consecutive_failures >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Renewal-worker query path: "find subs that are due to bill next."
|
||||||
|
-- Partial index on the only states that ever produce work.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_next_renewal
|
||||||
|
ON subscriptions(next_renewal_at)
|
||||||
|
WHERE status IN ('active', 'past_due');
|
||||||
|
|
||||||
|
-- Admin-UI search paths.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_status ON subscriptions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_license ON subscriptions(license_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_policy ON subscriptions(policy_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- subscription_invoices: one row per renewal-cycle invoice
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Joins subscriptions to the existing invoices table so we can ask
|
||||||
|
-- "show me all the invoices for this subscription." Why a separate
|
||||||
|
-- table rather than a column on `invoices`: most invoices are NOT
|
||||||
|
-- subscription-related (one-shot purchases). A nullable FK column
|
||||||
|
-- on invoices works, but a separate join table keeps subscription-
|
||||||
|
-- specific metadata (cycle_number, cycle_start_at, cycle_end_at) out
|
||||||
|
-- of the main invoices schema and makes "list all sub invoices for
|
||||||
|
-- a license" a clean two-table join.
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_invoices (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4
|
||||||
|
subscription_id TEXT NOT NULL,
|
||||||
|
invoice_id TEXT NOT NULL, -- FK into invoices(id)
|
||||||
|
cycle_number INTEGER NOT NULL, -- 1, 2, 3, ... per subscription
|
||||||
|
cycle_start_at TEXT NOT NULL, -- ISO-8601 UTC; begin of the period this invoice covers
|
||||||
|
cycle_end_at TEXT NOT NULL, -- ISO-8601 UTC; end of the period
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id),
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices(id),
|
||||||
|
UNIQUE (subscription_id, cycle_number),
|
||||||
|
CHECK (cycle_number >= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_invoices_sub
|
||||||
|
ON subscription_invoices(subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_invoices_invoice
|
||||||
|
ON subscription_invoices(invoice_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Validation: recurring policies need a renewal period.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- We can't add this as a CHECK on the policies table because SQLite
|
||||||
|
-- ALTER TABLE doesn't support adding CHECKs (and rebuilding the
|
||||||
|
-- table here just to add one constraint is overkill given how
|
||||||
|
-- rarely operators flip is_recurring on existing rows). The repo
|
||||||
|
-- helper `create_policy_recurring` enforces it at write time
|
||||||
|
-- instead. A hypothetical future migration that rebuilds the
|
||||||
|
-- policies table for some other reason can add it then.
|
||||||
@@ -563,6 +563,107 @@ async fn migration_0010_backfills_existing_products_to_sat() {
|
|||||||
assert_db_clean(&pool).await.expect("db clean after 0010");
|
assert_db_clean(&pool).await.expect("db clean after 0010");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migration 0011 (subscriptions schema): verifies that adding the
|
||||||
|
/// new policies columns + the subscriptions / subscription_invoices
|
||||||
|
/// tables doesn't break existing data, and that the new tables
|
||||||
|
/// accept rows via FK references back to licenses / policies /
|
||||||
|
/// invoices created under the prior schema.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migration_0011_adds_subscriptions_without_breaking_existing_data() {
|
||||||
|
let (pool, _tmp) = make_pool().await;
|
||||||
|
|
||||||
|
// Apply everything before 0011, populate realistic state.
|
||||||
|
apply_range(&pool, 0, 10)
|
||||||
|
.await
|
||||||
|
.expect("apply 0001..=0010");
|
||||||
|
seed_realistic_fixtures(&pool)
|
||||||
|
.await
|
||||||
|
.expect("seed pre-0011 fixtures");
|
||||||
|
|
||||||
|
// Apply 0011.
|
||||||
|
apply_range(&pool, 10, 11)
|
||||||
|
.await
|
||||||
|
.expect("apply 0011_subscriptions");
|
||||||
|
|
||||||
|
// New policies columns exist with sensible defaults on existing rows.
|
||||||
|
let (is_recurring, period, grace, trial): (i64, Option<i64>, i64, i64) = sqlx::query_as(
|
||||||
|
"SELECT is_recurring, renewal_period_days, grace_period_days, trial_days \
|
||||||
|
FROM policies WHERE id = 'pol1'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(is_recurring, 0, "existing policies must default to non-recurring");
|
||||||
|
assert_eq!(period, None, "renewal_period_days should be NULL on non-recurring rows");
|
||||||
|
assert_eq!(grace, 7, "grace_period_days default should be 7");
|
||||||
|
assert_eq!(trial, 0, "trial_days default should be 0");
|
||||||
|
|
||||||
|
// The new tables exist and accept a subscription tied to the
|
||||||
|
// existing fixture license.
|
||||||
|
let now = "2026-05-08T12:00:00Z";
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
|
||||||
|
listed_currency, listed_value, status, started_at, next_renewal_at, \
|
||||||
|
created_at, updated_at) \
|
||||||
|
VALUES('sub1', 'lic1', 'pol1', 'p1', 30, 'USD', 2500, 'active', ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.bind("2026-06-08T12:00:00Z")
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("insert subscription with FKs into pre-0011 rows");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO subscription_invoices(id, subscription_id, invoice_id, cycle_number, \
|
||||||
|
cycle_start_at, cycle_end_at, created_at) \
|
||||||
|
VALUES('si1', 'sub1', 'inv1', 1, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.bind("2026-06-08T12:00:00Z")
|
||||||
|
.bind(now)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("subscription_invoices accepts rows");
|
||||||
|
|
||||||
|
// Status CHECK constraint enforced.
|
||||||
|
let bad = sqlx::query(
|
||||||
|
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
|
||||||
|
listed_currency, listed_value, status, started_at, created_at, updated_at) \
|
||||||
|
VALUES('sub2', 'lic1', 'pol1', 'p1', 30, 'USD', 2500, 'garbage', ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
bad.is_err(),
|
||||||
|
"unknown subscription status should be rejected by CHECK"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The cycle_number UNIQUE constraint prevents accidental
|
||||||
|
// double-billing for the same cycle.
|
||||||
|
let dup = sqlx::query(
|
||||||
|
"INSERT INTO subscription_invoices(id, subscription_id, invoice_id, cycle_number, \
|
||||||
|
cycle_start_at, cycle_end_at, created_at) \
|
||||||
|
VALUES('si2', 'sub1', 'inv1', 1, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.bind("2026-06-08T12:00:00Z")
|
||||||
|
.bind(now)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
dup.is_err(),
|
||||||
|
"(subscription_id, cycle_number) must be UNIQUE — same cycle twice should fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// FK + integrity invariants.
|
||||||
|
assert_db_clean(&pool).await.expect("db clean after 0011");
|
||||||
|
}
|
||||||
|
|
||||||
/// Future-proofing. Always seeds fixtures one migration before the end,
|
/// Future-proofing. Always seeds fixtures one migration before the end,
|
||||||
/// then applies the final migration. As new migrations land (0010,
|
/// then applies the final migration. As new migrations land (0010,
|
||||||
/// 0011, …), they get vetted against populated data automatically; no
|
/// 0011, …), they get vetted against populated data automatically; no
|
||||||
|
|||||||
Reference in New Issue
Block a user