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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user