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
+15 -3
View File
@@ -639,9 +639,17 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
}} }}
if (unitEl) unitEl.textContent = unitText; if (unitEl) unitEl.textContent = unitText;
if (priceLabel) priceLabel.textContent = 'Price · ' + t.name; if (priceLabel) priceLabel.textContent = 'Price · ' + t.name;
// Free tier: render "FREE", swap CTA to "Redeem license" so the // Trial recurring tier: first cycle is free, daemon issues the
// buyer never sees "Pay with Bitcoin" for a 0-amount product. // license inline. Surface that as a "Start N-day free trial"
if (fmt.isFree) {{ // CTA instead of "Pay with Bitcoin" so the buyer knows they
// aren't charged today. Renewal copy stays in the price unit
// suffix ("$25 / mo") so they can still see what happens after.
if (t.is_recurring && (t.trial_days || 0) > 0) {{
priceCurrent.textContent = 'FREE';
if (unitEl) unitEl.textContent = ' for ' + t.trial_days + ' days';
setTrialButton(t.trial_days);
}} else if (fmt.isFree) {{
// Free non-trial tier: "Redeem license".
priceCurrent.textContent = 'FREE'; priceCurrent.textContent = 'FREE';
if (unitEl) unitEl.textContent = ''; if (unitEl) unitEl.textContent = '';
setRedeemButton(); setRedeemButton();
@@ -699,6 +707,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
btnLabel.textContent = 'Redeem license'; btnLabel.textContent = 'Redeem license';
btnIcon.style.display = 'none'; btnIcon.style.display = 'none';
}} }}
function setTrialButton(days) {{
btnLabel.textContent = 'Start ' + (days || 7) + '-day free trial';
btnIcon.style.display = 'none';
}}
// Reset apply state if the buyer edits the code after a successful Apply. // Reset apply state if the buyer edits the code after a successful Apply.
codeInput.addEventListener('input', function() {{ codeInput.addEventListener('input', function() {{
+8
View File
@@ -443,6 +443,14 @@ pub fn router(state: AppState) -> Router {
"/v1/admin/self-license", "/v1/admin/self-license",
get(self_license::status).post(self_license::activate), get(self_license::status).post(self_license::activate),
) )
// Manual self-tier refresh — re-reads live entitlements
// from the local DB. Operators hit this after a Change
// Tier to update the running daemon immediately instead of
// waiting for the hourly background refresher.
.route(
"/v1/admin/self-license/refresh",
post(self_license::refresh),
)
// Issuer-key import — admin-only, master-bootstrap path. No // Issuer-key import — admin-only, master-bootstrap path. No
// StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md. // StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md.
.route("/v1/admin/import-issuer-key", post(issuer_key::import)) .route("/v1/admin/import-issuer-key", post(issuer_key::import))
+87
View File
@@ -148,6 +148,93 @@ pub async fn start(
conversion.sats conversion.sats
}; };
// ----- Free-trial shortcut (recurring + trial_days > 0) -----
// Before any pricing / discount logic: if the chosen policy is a
// recurring subscription with trial_days > 0, the buyer pays
// nothing today. We synthesize a settled free invoice, issue the
// license inline with expires_at = now + trial_days, and create
// the subscription row with next_renewal_at = trial_end so the
// renewal worker fires the FIRST paid invoice when the trial
// ends. Discount codes are deliberately ignored for trials —
// they're already free; layering a discount on a free first
// cycle is a no-op that just complicates the audit trail.
if let Some(p) = chosen_policy.as_ref() {
if p.is_recurring && p.trial_days > 0 {
let free_invoice = repo::create_free_invoice(
&state.db,
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
Some(p.id.as_str()),
)
.await?;
// issue_license_for_invoice handles the recurring branch
// (creates the subscription with next_renewal_at = trial_end)
// because we now special-case is_recurring + trial_days
// inside that function.
let license_id = crate::api::webhook::issue_license_for_invoice(
&state, &free_invoice,
)
.await?;
// Re-derive the signed key.
let lic = repo::get_license_by_invoice(&state.db, &free_invoice.id)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!("license vanished after issue"))
})?;
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
let expires_at_unix = lic
.expires_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.timestamp())
.unwrap_or(0);
let payload = LicensePayload {
version: KEY_VERSION_V2,
flags,
product_id: uuid::Uuid::parse_str(&lic.product_id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad product_id: {e}")))?,
license_id: uuid::Uuid::parse_str(&lic.id)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad license_id: {e}")))?,
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at: expires_at_unix,
fingerprint_hash: [0u8; 32],
entitlements: lic.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
let license_key = encode_key(&payload, &sig);
let poll_url = format!(
"{}/v1/purchase/{}",
state.config.public_base_url, free_invoice.id
);
tracing::info!(
product_slug = %req.product,
policy_slug = %p.slug,
trial_days = p.trial_days,
license_id = %license_id,
"trial license issued — no charge for first cycle"
);
return Ok(Json(StartPurchaseResp {
invoice_id: free_invoice.id.clone(),
btcpay_invoice_id: free_invoice.btcpay_invoice_id.clone(),
checkout_url: String::new(),
amount_sats: 0,
base_price_sats: base_price,
discount_applied_sats: 0,
poll_url,
license_key: Some(license_key),
license_id: Some(license_id),
}));
}
}
// Resolve and validate the discount code if one was supplied. The // Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot // ordering here matters: we must atomically reserve a counter slot
// BEFORE we create the BTCPay invoice, so that a code-cap race can't // BEFORE we create the BTCPay invoice, so that a code-cap race can't
+12
View File
@@ -170,3 +170,15 @@ pub async fn activate(
) )
.into_response()) .into_response())
} }
/// `POST /v1/admin/self-license/refresh` — re-read the daemon's
/// own license row from the local DB and update `state.self_tier`
/// with the live entitlements. Useful right after an admin
/// Change Tier when the operator doesn't want to wait for the
/// hourly background refresher.
pub async fn refresh(State(state): State<AppState>) -> Json<TierStatus> {
let current = state.self_tier.read().await.clone();
let next = license_self::refresh_self_tier_from_db(&state.db, &current).await;
*state.self_tier.write().await = next.clone();
Json(tier_to_status(&next))
}
+101 -2
View File
@@ -191,7 +191,18 @@ pub async fn issue_license_for_invoice(
let now = Utc::now(); let now = Utc::now();
let issued_at = now.to_rfc3339(); let issued_at = now.to_rfc3339();
let duration_seconds = policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0); // For recurring policies with a free-trial period, the FIRST license's
// expires_at is the trial end, not the policy's duration_seconds. The
// renewal worker will extend on settle when the buyer pays the first
// real cycle. trial_days = 0 (no trial) falls through to the regular
// duration_seconds path.
let is_recurring = policy.as_ref().map(|p| p.is_recurring).unwrap_or(false);
let trial_days = policy.as_ref().map(|p| p.trial_days).unwrap_or(0);
let duration_seconds = if is_recurring && trial_days > 0 {
trial_days * 86_400
} else {
policy.as_ref().map(|p| p.duration_seconds).unwrap_or(0)
};
let expires_at = if duration_seconds == 0 { let expires_at = if duration_seconds == 0 {
None None
} else { } else {
@@ -199,7 +210,10 @@ pub async fn issue_license_for_invoice(
}; };
let grace_seconds = policy.as_ref().map(|p| p.grace_seconds).unwrap_or(0); let grace_seconds = policy.as_ref().map(|p| p.grace_seconds).unwrap_or(0);
let max_machines = policy.as_ref().map(|p| p.max_machines).unwrap_or(1); let max_machines = policy.as_ref().map(|p| p.max_machines).unwrap_or(1);
let is_trial = policy.as_ref().map(|p| p.is_trial).unwrap_or(false); // For trial recurring licenses, set the TRIAL flag on the signed
// payload too, so SDKs can render "trial — N days remaining".
let is_trial =
policy.as_ref().map(|p| p.is_trial).unwrap_or(false) || (is_recurring && trial_days > 0);
let entitlements = policy let entitlements = policy
.as_ref() .as_ref()
.map(|p| p.entitlements.clone()) .map(|p| p.entitlements.clone())
@@ -234,6 +248,91 @@ pub async fn issue_license_for_invoice(
"license issued for settled invoice" "license issued for settled invoice"
); );
// Recurring policy: create the subscription row that the renewal
// worker uses as its source of truth. Uses the invoice's listed
// currency + value if available (multi-currency support); falls
// back to SAT + invoice.amount_sats for legacy / SAT-only setups.
//
// First-cycle scheduling:
// - trial_days > 0: next_renewal_at = now + trial_days. When the
// trial ends, the renewal worker creates the FIRST paid invoice.
// - trial_days = 0: next_renewal_at = now + period_days. Buyer
// already paid for the current cycle; renewal worker creates
// the second cycle's invoice when this one ends.
if let Some(p) = policy.as_ref() {
if p.is_recurring {
let period_days = p.renewal_period_days.max(1);
let first_cycle_days = if p.trial_days > 0 { p.trial_days } else { period_days };
let listed_currency = invoice
.listed_currency
.clone()
.unwrap_or_else(|| "SAT".to_string());
let listed_value = invoice
.listed_value
.unwrap_or(invoice.amount_sats);
let existing = crate::subscriptions::get_subscription_by_license_id(
&state.db,
&license_id,
)
.await
.ok()
.flatten();
if existing.is_none() {
match crate::subscriptions::create_subscription(
&state.db,
&license_id,
&p.id,
&invoice.product_id,
period_days,
&listed_currency,
listed_value,
&invoice.id,
)
.await
{
Ok(sub) => {
// Override next_renewal_at to the first-cycle window.
// create_subscription defaults to now + period_days;
// for trials we want now + trial_days. Cheap UPDATE.
if first_cycle_days != period_days {
let trial_end = (Utc::now()
+ chrono::Duration::days(first_cycle_days))
.to_rfc3339();
let _ = sqlx::query(
"UPDATE subscriptions SET next_renewal_at = ?, \
updated_at = ? WHERE id = ?",
)
.bind(&trial_end)
.bind(&trial_end)
.bind(&sub.id)
.execute(&state.db)
.await;
}
tracing::info!(
license_id = %license_id,
policy_id = %p.id,
period_days,
first_cycle_days,
listed_currency,
listed_value,
trial = (first_cycle_days != period_days),
"subscription created for recurring purchase"
);
}
Err(e) => {
tracing::warn!(
license_id = %license_id,
policy_id = %p.id,
error = %e,
"failed to create subscription row on recurring purchase; \
license issued but renewal worker will not pick this up"
);
}
}
}
}
}
// Fire-and-forget Lightning tip to the policy's configured recipient, // Fire-and-forget Lightning tip to the policy's configured recipient,
// if any. This never blocks issuance: errors are logged + audited inside // if any. This never blocks issuance: errors are logged + audited inside
// the spawned task. Skipped silently when the policy has no tip config. // the spawned task. Skipped silently when the policy has no tip config.
+5
View File
@@ -472,6 +472,11 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
policy_id: row.try_get("policy_id").ok().flatten(), policy_id: row.try_get("policy_id").ok().flatten(),
listed_currency: row
.try_get::<Option<String>, _>("listed_currency")
.ok()
.flatten(),
listed_value: row.try_get::<Option<i64>, _>("listed_value").ok().flatten(),
} }
} }
+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()
}
}
+33
View File
@@ -155,6 +155,39 @@ async fn main() -> anyhow::Result<()> {
}); });
} }
// Self-tier live refresher — re-reads the daemon's own license
// row from the local DB every hour and updates `state.self_tier`
// with the live entitlements. Without this, an admin Change Tier
// on the daemon's own license never propagates to the running
// process (boot-time check trusts the signed payload's
// entitlements, which are immutable). See
// `license_self::refresh_self_tier_from_db` for the rationale.
{
// Initial refresh — fires once now so an operator who changed
// their tier between two daemon runs sees the new tier before
// the first interval tick.
let current = state.self_tier.read().await.clone();
let next = license_self::refresh_self_tier_from_db(&state.db, &current).await;
*state.self_tier.write().await = next;
// Periodic refresh thereafter.
let state2 = state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Burn the immediate-fire tick so the first real tick is
// 1h from spawn (we already ran the initial refresh above).
interval.tick().await;
loop {
interval.tick().await;
let current = state2.self_tier.read().await.clone();
let next =
license_self::refresh_self_tier_from_db(&state2.db, &current).await;
*state2.self_tier.write().await = next;
}
});
}
let app = api::router(state).layer(TraceLayer::new_for_http()); let app = api::router(state).layer(TraceLayer::new_for_http());
// --- serve --- // --- serve ---
+9
View File
@@ -81,6 +81,15 @@ pub struct Invoice {
/// product's default policy. Migration 0007 adds the column. /// product's default policy. Migration 0007 adds the column.
#[serde(default)] #[serde(default)]
pub policy_id: Option<String>, pub policy_id: Option<String>,
/// Listed currency the invoice was priced in. NULL on pre-multi-currency
/// invoices (migration 0010+); fall back to "SAT" in that case.
#[serde(default)]
pub listed_currency: Option<String>,
/// Price in the listed currency's smallest unit (sats for SAT, cents
/// for USD/EUR). NULL on pre-multi-currency invoices; fall back to
/// `amount_sats` (which is correct for SAT-priced products).
#[serde(default)]
pub listed_value: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+28 -1
View File
@@ -717,19 +717,46 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
.context("UPDATE subscriptions on renewal create")?; .context("UPDATE subscriptions on renewal create")?;
// 9. Webhook event: operator's app gets notified that a // 9. Webhook event: operator's app gets notified that a
// renewal invoice exists and the buyer needs to pay. // renewal invoice exists and the buyer needs to pay. The
// operator's webhook receiver renders an email / push /
// in-app notification with `checkout_url` and sends it to
// `buyer_email` (Keysat does not email buyers itself —
// operator-driven communication, same as license issuance).
//
// `is_first_paid_cycle` lets operators distinguish "your
// free trial is ending, here's the first real charge" from
// "your monthly renewal is due" — different copy is usually
// appropriate.
let buyer_email: Option<String> = sqlx::query_scalar(
"SELECT buyer_email FROM licenses WHERE id = ?",
)
.bind(&sub.license_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let is_first_paid_cycle = next_cycle_num == 2;
crate::webhooks::dispatch( crate::webhooks::dispatch(
state, state,
"subscription.renewal_pending", "subscription.renewal_pending",
&json!({ &json!({
"subscription_id": sub.id, "subscription_id": sub.id,
"license_id": sub.license_id, "license_id": sub.license_id,
"product_id": sub.product_id,
"policy_id": sub.policy_id,
"invoice_id": internal_invoice_id, "invoice_id": internal_invoice_id,
"checkout_url": handle.checkout_url, "checkout_url": handle.checkout_url,
"amount_sats": amount_sats, "amount_sats": amount_sats,
"listed_currency": sub.listed_currency, "listed_currency": sub.listed_currency,
"listed_value": sub.listed_value, "listed_value": sub.listed_value,
"cycle_number": next_cycle_num, "cycle_number": next_cycle_num,
"cycle_start_at": cycle_start.to_rfc3339(),
"cycle_end_at": cycle_end.to_rfc3339(),
"due_at": cycle_end.to_rfc3339(),
"buyer_email": buyer_email,
"is_first_paid_cycle": is_first_paid_cycle,
}), }),
) )
.await; .await;