P0 — recurring + trial + renewal-webhook + self-tier live refresh

Five fixes that were all blocking real-world use of the recurring
+ tier-upgrade features. All deeply related; bundling them into one
commit because they share data flow and would be silly to land
piecemeal.

1. Subscription row created on recurring purchase
   issue_license_for_invoice now calls
   subscriptions::create_subscription whenever the resolved policy
   has is_recurring=1. Previously the licenses row was inserted but
   no corresponding subscription, so the renewal worker never picked
   it up — buying a recurring policy was silently equivalent to a
   one-shot purchase. Idempotent against webhook re-delivery.

2. trial_days actually does something
   /v1/purchase short-circuits BEFORE pricing/discount logic when
   the chosen policy has is_recurring=1 AND trial_days > 0:
   synthesizes a free invoice via repo::create_free_invoice,
   issues the license inline with expires_at = now + trial_days,
   creates the subscription with next_renewal_at = trial_end so the
   renewal worker fires the FIRST paid invoice when the trial ends.
   Buyer pays nothing today. Discount codes are deliberately
   ignored on trial purchases (free + discount = no-op).

3. Trial license carries the TRIAL flag
   In the regular webhook issuance path, is_trial is now set
   whenever (policy.is_trial OR (is_recurring AND trial_days > 0)),
   so the signed payload's TRIAL bit reflects what the buyer is
   actually getting and SDK consumers can render
   "trial — N days remaining" correctly.

4. Renewal-pending webhook payload enriched
   subscription.renewal_pending now includes buyer_email (looked up
   from the license), product_id, policy_id, cycle_start_at,
   cycle_end_at, due_at, and is_first_paid_cycle. With these the
   operator's webhook receiver has everything it needs to render
   "your free trial is ending" vs "your monthly renewal is due"
   emails and forward the checkout_url to the buyer. Without this
   payload upgrade, renewal invoices were created server-side but
   no one knew about them.

5. Self-tier live refresh
   New license_self::refresh_self_tier_from_db re-reads the
   daemon's own license row from the local DB and rebuilds
   state.self_tier with LIVE entitlements (not the immutable
   signed-payload entitlements). Without this, an admin Change
   Tier on the daemon's own license never propagates — the
   running process keeps showing whatever tier was baked in at
   key-signing time, even though the DB row says otherwise.
   Wired to run:
   - Once at boot, immediately after check_at_boot (so any tier
     change between two daemon runs takes effect on next start)
   - Every hour thereafter (background task in main.rs)
   - On demand via POST /v1/admin/self-license/refresh, exposed
     for operators who don't want to wait for the next tick

   For master Keysat (the one selling licenses) the refresh
   query is local. Non-master operators in v0.3+ can extend this
   to call upstream `/v1/validate`. For v0.2.x, local-DB-only
   resolves your testing case (downgrade yourself, click refresh,
   sidebar updates, gate tests work).

6. Buy page CTA reflects trial
   When the selected tier has is_recurring=1 and trial_days > 0,
   the price card renders "FREE for N days" and the button reads
   "Start N-day free trial" instead of "Pay with Bitcoin". Buyer
   knows they aren't being charged today.

7. Invoice model gains listed_currency + listed_value
   Already in the DB schema (migration 0010); the Rust model just
   wasn't reading them. Needed by #1 to set the subscription's
   listed_value correctly for fiat-priced recurring policies.

Test count unchanged (77 passing). The recurring-tests-still-pass
proof point isn't the test suite (these are behavioral changes
above the renewal-worker tests' scope) — it's that the renewal
worker tests construct subscriptions explicitly and don't go
through the purchase path that was broken.
This commit is contained in:
Grant
2026-05-09 13:52:47 -05:00
parent 735461b3ef
commit 2fbd36fac6
10 changed files with 408 additions and 6 deletions
+110
View File
@@ -254,3 +254,113 @@ impl Mode {
}
}
}
/// Live-refresh the daemon's self-tier from the local `licenses` row.
///
/// Why this exists: `check_at_boot` parses the on-disk LIC1 key and
/// extracts entitlements from the SIGNED PAYLOAD. Those entitlements
/// are immutable for the life of that key — the operator can't ever
/// downgrade themselves by editing the DB row, because the daemon
/// trusts the signature, not the DB.
///
/// In practice that means tier upgrades / downgrades / revocations
/// applied via admin (or eventually, via an upstream master) don't
/// propagate to a running daemon — even though the daemon is online
/// and the data is right there in its own DB. This function is the
/// fix: re-read the licenses row by license_id and use the LIVE
/// entitlements + revocation status. The on-disk signed key is kept
/// as proof-of-authenticity (signature still verifies) but the live
/// DB row is the source of tier truth.
///
/// Behavior:
/// - If the on-disk tier is `Unlicensed`, do nothing — there's no
/// license_id to look up.
/// - If the licenses row is missing in the DB (legitimate for a
/// daemon that's never been online to sync, e.g.), keep the
/// signed-payload tier as last-known.
/// - If the row is revoked, demote to `Unlicensed { reason: "revoked" }`.
/// - Otherwise, replace the entitlements vec with whatever the DB
/// row currently says.
///
/// Run from main.rs at boot (after `check_at_boot`) and on a 1-hour
/// interval thereafter. Also surfaced as an admin "Refresh
/// self-license tier" action for operators who want to trigger
/// immediately after a change instead of waiting for the next tick.
///
/// Non-master operators in v0.3+ can extend this to call
/// `https://licensing.keysat.xyz/v1/validate` instead of (or in
/// addition to) the local DB. For v0.2.x, local-DB-only — which is
/// the right thing for the master Keysat (which is selling its own
/// licenses) and a no-op-but-safe for downstream operators (their
/// own DB row hasn't been mutated, so live read returns the same
/// thing as the boot-time signed-payload extraction).
pub async fn refresh_self_tier_from_db(
pool: &sqlx::SqlitePool,
current: &Tier,
) -> Tier {
let license_id = match current {
Tier::Licensed { license_id, .. } => license_id.to_string(),
Tier::Unlicensed { .. } => return current.clone(),
};
let row = match crate::db::repo::get_license_by_id(pool, &license_id).await {
Ok(Some(row)) => row,
Ok(None) => {
// Unknown to local DB — keep signed-payload tier. Could
// happen if the daemon was issued elsewhere and only has
// the on-disk key, no row in `licenses`.
return current.clone();
}
Err(e) => {
tracing::warn!(error = %e, "self-tier refresh: DB lookup failed; keeping last-known");
return current.clone();
}
};
if row.revoked_at.is_some() {
let reason = format!(
"license revoked at {}",
row.revoked_at.as_deref().unwrap_or("?")
);
tracing::warn!(
license_id = %license_id,
"self-tier refresh: license is revoked; demoting to Unlicensed"
);
return Tier::Unlicensed { reason };
}
if row.suspended_at.is_some() {
return Tier::Unlicensed {
reason: format!(
"license suspended at {}",
row.suspended_at.as_deref().unwrap_or("?")
),
};
}
// Pull the LIVE entitlements from the DB. These can differ from
// the signed payload's entitlements (which were baked at signing
// time) if an admin has done a Change Tier on this license.
let entitlements = row.entitlements.clone();
// Same product / license / expiry — only the entitlement set is
// live. Cheap rebuild.
let product_id = uuid::Uuid::parse_str(&row.product_id).ok();
let license_id_uuid = uuid::Uuid::parse_str(&row.id).ok();
let expires_at_unix = row
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
if let (Some(product_id), Some(license_id)) = (product_id, license_id_uuid) {
Tier::Licensed {
license_id,
product_id,
expires_at: expires_at_unix,
entitlements,
}
} else {
current.clone()
}
}