From 8ce78ab9d3feadeb99b65205537738cb721d7207 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 19:33:08 -0500 Subject: [PATCH] =?UTF-8?q?Tier=20upgrades=20Phase=201=20=E2=80=94=20schem?= =?UTF-8?q?a=20foundation=20(dormant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../migrations/0013_tier_upgrades.sql | 119 ++++++++++++++++++ licensing-service/tests/migrations.rs | 105 ++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 licensing-service/migrations/0013_tier_upgrades.sql diff --git a/licensing-service/migrations/0013_tier_upgrades.sql b/licensing-service/migrations/0013_tier_upgrades.sql new file mode 100644 index 0000000..a8aa3a2 --- /dev/null +++ b/licensing-service/migrations/0013_tier_upgrades.sql @@ -0,0 +1,119 @@ +-- Tier upgrades: schema foundation. +-- +-- This migration adds the storage shape needed for in-place tier +-- upgrades + downgrades on existing licenses (Standard → Pro, +-- Trial → Standard, etc.). Daemon code that USES these columns + +-- table lands in subsequent commits per TIER_UPGRADES_DESIGN.md +-- Phases 2-6. +-- +-- Strategy: additive only. Existing licenses + policies are +-- untouched. A policy becomes "part of the tier ladder" by getting +-- a `tier_rank` value; policies with NULL tier_rank are excluded +-- from buyer-facing upgrade flows (admin can still force-change +-- to/from any policy). This means existing operators who don't +-- want tier upgrades can ignore the feature entirely — none of +-- their policies are in any ladder until they opt in by setting +-- a rank. + +PRAGMA foreign_keys = ON; + +-- --------------------------------------------------------------------------- +-- policies: tier_rank for ladder ordering +-- --------------------------------------------------------------------------- +-- Operator-defined ordering. Higher rank = better tier. A product +-- can have policies "free" (rank 0), "standard" (rank 1), "pro" +-- (rank 2), "patron" (rank 3). The tier-upgrade endpoint validates +-- that target.tier_rank > current.tier_rank for upgrades, and the +-- reverse for downgrades. NULL = excluded from the buyer-facing +-- ladder (e.g. limited-edition promo policy that shouldn't appear +-- as an upgrade target). +-- +-- We don't enforce uniqueness within a product — operators can +-- legitimately have two policies at the same rank (e.g. "Pro +-- Monthly" and "Pro Annual" both at rank=2 — same entitlements, +-- different cadence). Sideways changes between same-rank policies +-- are admin-only; the buyer endpoint rejects them. +ALTER TABLE policies ADD COLUMN tier_rank INTEGER; + +-- Index supports the common "list this product's policies in +-- ladder order" query used by both the admin tier-rank picker and +-- the buyer-side tier listing. +CREATE INDEX IF NOT EXISTS idx_policies_tier_rank + ON policies(product_id, tier_rank); + +-- --------------------------------------------------------------------------- +-- tier_changes: audit trail of every tier change ever applied +-- --------------------------------------------------------------------------- +-- One row per upgrade or downgrade. The `licenses.policy_id` column +-- still holds the CURRENT tier; this table is the history. Operators +-- can answer "what tier was this license on as of date X" by walking +-- tier_changes ordered by created_at; combined with +-- effective_at, "is the license currently entitled to " is also a +-- cheap lookup against licenses.policy_id alone (no walk needed). +-- +-- effective_at is decoupled from created_at for downgrades on +-- recurring subs: the downgrade is RECORDED immediately (created_at) +-- but doesn't TAKE EFFECT until the end of the current cycle +-- (effective_at = cycle_end). For upgrades, effective_at usually +-- equals created_at (immediate on payment settle). +CREATE TABLE IF NOT EXISTS tier_changes ( + id TEXT PRIMARY KEY, -- UUID v4 + license_id TEXT NOT NULL, + from_policy_id TEXT NOT NULL, + to_policy_id TEXT NOT NULL, + direction TEXT NOT NULL, -- 'upgrade' | 'downgrade' + + -- Pricing snapshot. The proration math (and the rate fetcher + -- for fiat conversions) runs at quote time and is frozen here + -- once the change is applied. For comp-mode admin changes + -- (skip_payment=true), proration_charge_value is 0 and + -- invoice_id is NULL. + listed_currency TEXT NOT NULL, -- 'SAT' | 'USD' | 'EUR' + proration_charge_value INTEGER NOT NULL DEFAULT 0, -- smallest unit of listed_currency + invoice_id TEXT, -- nullable: 0-charge changes have no invoice + + -- When the new entitlements take effect. For upgrades on + -- recurring subs OR perpetual: typically same as created_at. + -- For downgrades on recurring subs: end of current cycle. + effective_at TEXT NOT NULL, + + -- Audit. 'buyer' = self-service via /v1/upgrade. + -- 'admin' = operator action via /v1/admin/licenses/:id/change-tier. + actor TEXT NOT NULL, + -- Optional free-form note. Audit-only; not user-visible. The + -- admin endpoint accepts a `reason` field that lands here. + reason TEXT, + + created_at TEXT NOT NULL, + + FOREIGN KEY (license_id) REFERENCES licenses(id), + FOREIGN KEY (from_policy_id) REFERENCES policies(id), + FOREIGN KEY (to_policy_id) REFERENCES policies(id), + FOREIGN KEY (invoice_id) REFERENCES invoices(id), + CHECK (direction IN ('upgrade', 'downgrade')), + CHECK (actor IN ('buyer', 'admin')), + CHECK (proration_charge_value >= 0) +); + +-- Admin-UI "show me this license's tier history" query path. +CREATE INDEX IF NOT EXISTS idx_tier_changes_license + ON tier_changes(license_id, created_at); +-- Operator analytics: "how many upgrades happened this month?" +CREATE INDEX IF NOT EXISTS idx_tier_changes_created + ON tier_changes(created_at); +-- Webhook-handler lookup: an invoice settles, we need to know +-- whether it's a tier-change invoice (vs a fresh purchase or a +-- subscription renewal). +CREATE INDEX IF NOT EXISTS idx_tier_changes_invoice + ON tier_changes(invoice_id) WHERE invoice_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- Note: no CHECK constraint enforcing that tier_rank is set on +-- policies that participate in upgrade flows. The check lives in +-- the API handler (api/upgrade.rs, future commit) because: +-- 1. SQLite ALTER TABLE doesn't support adding CHECKs. +-- 2. NULL tier_rank is a valid state for "this policy isn't in +-- any ladder" — there's nothing to enforce at the row level. +-- 3. The semantic check ("you can't upgrade to a policy with +-- NULL tier_rank") is a cross-row invariant the API layer +-- handles cleanly with a single SELECT. diff --git a/licensing-service/tests/migrations.rs b/licensing-service/tests/migrations.rs index b32d9f8..abbddde 100644 --- a/licensing-service/tests/migrations.rs +++ b/licensing-service/tests/migrations.rs @@ -664,6 +664,111 @@ async fn migration_0011_adds_subscriptions_without_breaking_existing_data() { assert_db_clean(&pool).await.expect("db clean after 0011"); } +/// Migration 0013 (tier upgrades schema): verifies that adding the +/// new `policies.tier_rank` column + the `tier_changes` table +/// doesn't break existing data, and that the new table accepts rows +/// via FK references back to licenses / policies / invoices created +/// under the prior schema. +#[tokio::test] +async fn migration_0013_adds_tier_upgrades_without_breaking_existing_data() { + let (pool, _tmp) = make_pool().await; + + // Apply everything before 0013, populate realistic state. + let total = migration_files().len(); + assert!(total >= 13, "need 13+ migrations to test 0013 in context"); + apply_range(&pool, 0, 12) + .await + .expect("apply 0001..=0012"); + seed_realistic_fixtures(&pool) + .await + .expect("seed pre-0013 fixtures"); + + // Apply 0013. + apply_range(&pool, 12, 13) + .await + .expect("apply 0013_tier_upgrades"); + + // The new column exists with NULL default on existing rows + // (existing operators didn't opt into tier ladders yet). + let rank: Option = sqlx::query_scalar( + "SELECT tier_rank FROM policies WHERE id = 'pol1'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + rank, None, + "existing policies must default to NULL tier_rank (out of any ladder)" + ); + + // The new tier_changes table accepts a row referencing the + // pre-existing fixture license + policy + invoice. + let now = "2026-05-08T12:00:00Z"; + sqlx::query( + "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ + direction, listed_currency, proration_charge_value, invoice_id, \ + effective_at, actor, reason, created_at) \ + VALUES('tc1', 'lic1', 'pol1', 'pol1', 'upgrade', 'USD', 3333, \ + 'inv1', ?, 'buyer', 'test upgrade', ?)", + ) + .bind(now) + .bind(now) + .execute(&pool) + .await + .expect("tier_changes accepts row with FKs into pre-0013 fixture rows"); + + // CHECK constraints enforced: bad direction value rejected. + let bad_direction = sqlx::query( + "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ + direction, listed_currency, effective_at, actor, created_at) \ + VALUES('tc2', 'lic1', 'pol1', 'pol1', 'sideways', 'USD', ?, 'buyer', ?)", + ) + .bind(now) + .bind(now) + .execute(&pool) + .await; + assert!( + bad_direction.is_err(), + "tier_changes.direction must be 'upgrade' or 'downgrade'" + ); + + // CHECK enforced: bad actor value rejected. + let bad_actor = sqlx::query( + "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ + direction, listed_currency, effective_at, actor, created_at) \ + VALUES('tc3', 'lic1', 'pol1', 'pol1', 'upgrade', 'USD', ?, 'system', ?)", + ) + .bind(now) + .bind(now) + .execute(&pool) + .await; + assert!( + bad_actor.is_err(), + "tier_changes.actor must be 'buyer' or 'admin'" + ); + + // CHECK enforced: negative proration value rejected (operator + // typo or buggy quote logic should fail loudly, not silently + // store a refund-shaped row in an upgrade-shaped table). + let bad_proration = sqlx::query( + "INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \ + direction, listed_currency, proration_charge_value, effective_at, \ + actor, created_at) \ + VALUES('tc4', 'lic1', 'pol1', 'pol1', 'upgrade', 'USD', -100, ?, 'admin', ?)", + ) + .bind(now) + .bind(now) + .execute(&pool) + .await; + assert!( + bad_proration.is_err(), + "tier_changes.proration_charge_value must be >= 0" + ); + + // FK + integrity invariants overall. + assert_db_clean(&pool).await.expect("db clean after 0013"); +} + /// Future-proofing. Always seeds fixtures one migration before the end, /// then applies the final migration. As new migrations land (0010, /// 0011, …), they get vetted against populated data automatically; no