diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index da274ad..2c70fd9 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -639,9 +639,17 @@ footer.kfooter a:hover {{ color:var(--navy-900); }} }} if (unitEl) unitEl.textContent = unitText; if (priceLabel) priceLabel.textContent = 'Price · ' + t.name; - // Free tier: render "FREE", swap CTA to "Redeem license" so the - // buyer never sees "Pay with Bitcoin" for a 0-amount product. - if (fmt.isFree) {{ + // Trial recurring tier: first cycle is free, daemon issues the + // license inline. Surface that as a "Start N-day free trial" + // 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'; if (unitEl) unitEl.textContent = ''; setRedeemButton(); @@ -699,6 +707,10 @@ footer.kfooter a:hover {{ color:var(--navy-900); }} btnLabel.textContent = 'Redeem license'; 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. codeInput.addEventListener('input', function() {{ diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index acc5a06..6aa9e41 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -443,6 +443,14 @@ pub fn router(state: AppState) -> Router { "/v1/admin/self-license", 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 // StartOS Action surface; documented in MASTER_KEYPAIR_PROCEDURE.md. .route("/v1/admin/import-issuer-key", post(issuer_key::import)) diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs index 4d78577..9c58641 100644 --- a/licensing-service/src/api/purchase.rs +++ b/licensing-service/src/api/purchase.rs @@ -148,6 +148,93 @@ pub async fn start( 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 // ordering here matters: we must atomically reserve a counter slot // BEFORE we create the BTCPay invoice, so that a code-cap race can't diff --git a/licensing-service/src/api/self_license.rs b/licensing-service/src/api/self_license.rs index 1f68469..8378d22 100644 --- a/licensing-service/src/api/self_license.rs +++ b/licensing-service/src/api/self_license.rs @@ -170,3 +170,15 @@ pub async fn activate( ) .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) -> Json { + let current = state.self_tier.read().await.clone(); + let next = license_self::refresh_self_tier_from_db(&state.db, ¤t).await; + *state.self_tier.write().await = next.clone(); + Json(tier_to_status(&next)) +} diff --git a/licensing-service/src/api/webhook.rs b/licensing-service/src/api/webhook.rs index 1273c07..e8587d7 100644 --- a/licensing-service/src/api/webhook.rs +++ b/licensing-service/src/api/webhook.rs @@ -191,7 +191,18 @@ pub async fn issue_license_for_invoice( let now = Utc::now(); 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 { None } 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 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 .as_ref() .map(|p| p.entitlements.clone()) @@ -234,6 +248,91 @@ pub async fn issue_license_for_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, // if any. This never blocks issuance: errors are logged + audited inside // the spawned task. Skipped silently when the policy has no tip config. diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 829ed1a..34826f9 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -472,6 +472,11 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice { created_at: row.get("created_at"), updated_at: row.get("updated_at"), policy_id: row.try_get("policy_id").ok().flatten(), + listed_currency: row + .try_get::, _>("listed_currency") + .ok() + .flatten(), + listed_value: row.try_get::, _>("listed_value").ok().flatten(), } } diff --git a/licensing-service/src/license_self.rs b/licensing-service/src/license_self.rs index 03616a0..a0c4174 100644 --- a/licensing-service/src/license_self.rs +++ b/licensing-service/src/license_self.rs @@ -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() + } +} diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index c874b92..d6ac0d8 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -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, ¤t).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, ¤t).await; + *state2.self_tier.write().await = next; + } + }); + } + let app = api::router(state).layer(TraceLayer::new_for_http()); // --- serve --- diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 1a980a4..7e8f39e 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -81,6 +81,15 @@ pub struct Invoice { /// product's default policy. Migration 0007 adds the column. #[serde(default)] pub policy_id: Option, + /// 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, + /// 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, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/licensing-service/src/subscriptions.rs b/licensing-service/src/subscriptions.rs index 8f94857..8b803e0 100644 --- a/licensing-service/src/subscriptions.rs +++ b/licensing-service/src/subscriptions.rs @@ -717,19 +717,46 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> { .context("UPDATE subscriptions on renewal create")?; // 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 = 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( state, "subscription.renewal_pending", &json!({ "subscription_id": sub.id, "license_id": sub.license_id, + "product_id": sub.product_id, + "policy_id": sub.policy_id, "invoice_id": internal_invoice_id, "checkout_url": handle.checkout_url, "amount_sats": amount_sats, "listed_currency": sub.listed_currency, "listed_value": sub.listed_value, "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;