Tier upgrades Phase 1 — schema foundation (dormant)
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).
This commit is contained in:
@@ -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 <X>" 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.
|
||||||
@@ -664,6 +664,111 @@ async fn migration_0011_adds_subscriptions_without_breaking_existing_data() {
|
|||||||
assert_db_clean(&pool).await.expect("db clean after 0011");
|
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<i64> = 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,
|
/// 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