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:
@@ -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() {{
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ¤t).await;
|
||||||
|
*state.self_tier.write().await = next.clone();
|
||||||
|
Json(tier_to_status(&next))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
let app = api::router(state).layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
// --- serve ---
|
// --- serve ---
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user