v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions

This commit is contained in:
Grant
2026-05-07 23:35:22 -05:00
parent 6ac118ae70
commit beedd07f07
27 changed files with 5576 additions and 134 deletions
+174 -5
View File
@@ -31,17 +31,34 @@ pub struct StartPurchaseReq {
pub redirect_url: Option<String>,
/// Optional discount / referral code (case-insensitive).
pub code: Option<String>,
/// Optional tier (policy slug). When set, the policy's
/// `price_sats_override` becomes the base price (if defined), and the
/// chosen policy is remembered on the invoice so it's used at license
/// issuance time. When omitted, the daemon falls back to the product's
/// default policy at issuance — same as pre-:27 behaviour.
pub policy_slug: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct StartPurchaseResp {
pub invoice_id: String, // our internal id
pub btcpay_invoice_id: String, // BTCPay's id (for debugging)
pub checkout_url: String, // URL the user opens to pay
/// Empty for the free-tier shortcut path (price = 0 after override/discount):
/// we synthesize a settled invoice locally and skip BTCPay entirely.
pub btcpay_invoice_id: String,
/// Non-empty on the paid path. On the free path, empty — the buyer should
/// be shown the license card directly using `license_key` below.
pub checkout_url: String,
pub amount_sats: i64, // what BTCPay was charged (post-discount)
pub base_price_sats: i64, // product list price (pre-discount)
pub discount_applied_sats: i64, // base - amount_sats; 0 if no code
pub poll_url: String, // where to check status
/// Set when the daemon issued the license inline (free tier or 100%-off).
/// When present, the client should display the license card directly
/// instead of redirecting to a BTCPay checkout.
#[serde(skip_serializing_if = "Option::is_none")]
pub license_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_id: Option<String>,
}
/// Floor for invoiced amount after a discount is applied. Set to 1 sat so
@@ -64,7 +81,38 @@ pub async fn start(
)));
}
let base_price = product.price_sats;
// Resolve the optional tier (policy_slug). The chosen policy must be
// active and public for it to be selectable from the public buy page.
// (The admin can still issue under non-public policies via /v1/admin/licenses.)
let chosen_policy = if let Some(ps) = req.policy_slug.as_deref().filter(|s| !s.is_empty()) {
let pol = repo::get_policy_by_slug(&state.db, &product.id, ps)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{ps}' for product '{}'",
req.product
))
})?;
if !pol.active {
return Err(AppError::BadRequest(format!(
"policy '{ps}' is not active"
)));
}
if !pol.public {
return Err(AppError::BadRequest(format!(
"policy '{ps}' is not available on the public buy page"
)));
}
Some(pol)
} else {
None
};
// Effective base price: policy override if set, else product price.
let base_price = chosen_policy
.as_ref()
.and_then(|p| p.price_sats_override)
.unwrap_or(product.price_sats);
// Resolve and validate the discount code if one was supplied. The
// ordering here matters: we must atomically reserve a counter slot
@@ -103,8 +151,19 @@ pub async fn start(
));
}
}
// Note: applies_to_policy_id is informational in v0.1 — the
// policy used at license-issuance time is the product's default.
// If the code is restricted to a specific policy and a tier was
// selected, they must match. If no tier was selected, the code is
// implicitly applied to the product's default policy at issuance
// time, which we accept here (v0.1.0:27+).
if let Some(restricted_pid) = &code.applies_to_policy_id {
if let Some(chosen) = &chosen_policy {
if restricted_pid != &chosen.id {
return Err(AppError::BadRequest(
"discount code does not apply to the selected tier".into(),
));
}
}
}
// Step B: atomic reserve.
repo::try_reserve_code_slot(&state.db, &code.id).await?;
@@ -116,6 +175,101 @@ pub async fn start(
(base_price, None, 0)
};
// ----- Free-tier shortcut -----
// If the post-discount, post-policy-override price came out at 0 sats
// (price_sats_override = 0 on a "free" tier, OR a 100%-off discount on
// a paid tier), skip BTCPay entirely. BTCPay refuses 0-sat invoices and
// would also waste a UI step that prompts the buyer to "pay" zero. We
// synthesize a settled invoice locally, issue the license inline, and
// return the signed key in the response. The buy page renders the
// license card directly.
if final_price <= 0 {
let free_invoice = repo::create_free_invoice(
&state.db,
&product.id,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
)
.await
.map_err(|e| {
// If we got a code reservation earlier, release it.
let pool = state.db.clone();
let code = reservation.clone();
tokio::spawn(async move {
if let Some(c) = code {
let _ = repo::release_code_slot(&pool, &c.id).await;
}
});
e
})?;
// If a discount code was applied, record the redemption.
if let Some(code) = &reservation {
let _ = repo::record_pending_redemption(
&state.db,
&code.id,
&free_invoice.id,
discount_applied,
base_price,
0,
)
.await;
}
// Issue the license. This finalizes the redemption row and fires
// license.issued + (if applicable) code.redeemed webhooks.
let license_id =
crate::api::webhook::issue_license_for_invoice(&state, &free_invoice).await?;
// Re-derive the signed key (same pattern as redeem.rs / status()).
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
);
return Ok(Json(StartPurchaseResp {
invoice_id: free_invoice.id.clone(),
btcpay_invoice_id: free_invoice.btcpay_invoice_id.clone(), // "free-<uuid>"
checkout_url: String::new(), // signal: no BTCPay
amount_sats: 0,
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
license_key: Some(license_key),
license_id: Some(license_id),
}));
}
// Pre-allocate an internal invoice id so we can pass it to BTCPay as
// metadata, letting us correlate webhook events back to our row even
// before we've persisted the BTCPay invoice id.
@@ -204,6 +358,7 @@ pub async fn start(
&checkout_url,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
)
.await
{
@@ -254,6 +409,8 @@ pub async fn start(
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
license_key: None,
license_id: None,
}))
}
@@ -269,6 +426,18 @@ fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
((base * bps) / 10_000).max(0).min(base) as i64
}
"fixed_sats" => amount.max(0).min(base_price_sats),
// 'set_price' = the buyer pays exactly `amount` sats regardless of
// base price. Compute it as a discount: subtract enough to land at
// `amount`. If `amount >= base_price_sats`, the code provides no
// benefit (discount = 0).
"set_price" => {
let target = amount.max(0);
if target >= base_price_sats {
0
} else {
base_price_sats - target
}
}
_ => 0,
}
}