Files
keysat/licensing-service/src/api/purchase.rs
T
Grant 094cf75e52 v0.2.0:20 — Multi-policy scope for discount codes
A discount code can now apply to a subset of policies on a product
(e.g. "Patron and Pro but not Creator") instead of being limited to
exactly one policy or the entire product.

- Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array
  of policy ids). Legacy `applies_to_policy_id` stays as the singular
  fallback when the JSON column is empty/NULL.
- `DiscountCode::allowed_policy_ids()` helper unifies multi + singular
  into one Vec. Purchase + preview scope checks consult it.
- `find_applicable_featured_discount` now narrows multi-policy
  candidates in Rust (small candidate set; index-friendly SQL would
  require json_each, deferred).
- Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs`
  (array) alongside the existing `policy_slug` (singular). Multi wins
  when both are present. PATCH does not allow scope edits — same rule
  as the singular field (disable + recreate to re-scope).
- UI: pill multi-select replaces the policy dropdown on the create
  form. Edit modal's scope label renders the comma-separated list.

UI + schema both back-compat: existing codes keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:01:51 -05:00

639 lines
26 KiB
Rust

//! Purchase flow:
//! 1. Client POSTs `/v1/purchase` with a product slug.
//! 2. We create a BTCPay invoice, stash a row, return the checkout URL.
//! 3. Client opens the URL, pays. BTCPay hits our webhook (see
//! [`crate::api::webhook`]) which marks the invoice 'settled' and
//! issues a license.
//! 4. Client polls `/v1/purchase/:invoice_id` until `license_key` is
//! present, then stores it locally.
use crate::api::AppState;
use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2};
use crate::db::repo;
use crate::error::{AppError, AppResult};
use crate::payment::{CreateInvoiceParams, Money};
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct StartPurchaseReq {
/// Product slug to buy.
pub product: String,
/// Optional email for receipt / future contact.
pub buyer_email: Option<String>,
/// Optional free-text note from the buyer.
pub buyer_note: Option<String>,
/// Optional URL the buyer should be returned to after payment.
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
/// 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
/// 100%-off codes still produce a real BTCPay invoice (and the buyer
/// experiences the purchase flow). 0-sat invoices aren't always supported
/// by BTCPay anyway.
const MIN_INVOICE_SATS: i64 = 1;
pub async fn start(
State(state): State<AppState>,
Json(req): Json<StartPurchaseReq>,
) -> AppResult<Json<StartPurchaseResp>> {
let product = repo::get_product_by_slug(&state.db, &req.product)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", req.product)))?;
if !product.active {
return Err(AppError::BadRequest(format!(
"product '{}' is not available for purchase",
req.product
)));
}
// 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 in sats. For SAT-priced products this is
// straightforward (policy override or product.price_sats). For
// fiat-priced products (USD, EUR), we convert the listed value
// to sats here using the daemon's rate fetcher — the rate gets
// recorded on the invoice row below for audit. The CONTRACT to
// the buyer is sat-denominated either way; the listed currency
// is just the operator's display preference.
//
// We capture the listed (currency, value) and the rate-source
// tuple so the invoice row carries the full audit trail.
let mut listed_currency: Option<String> = None;
let mut listed_value: Option<i64> = None;
let mut exchange_rate_centibps: Option<i64> = None;
let mut exchange_rate_source: Option<String> = None;
let base_price: i64 = if product.price_currency == "SAT" {
chosen_policy
.as_ref()
.and_then(|p| p.price_sats_override)
.unwrap_or(product.price_sats)
} else {
// Fiat-priced. Use the policy override (in the same currency
// as the product) if set, otherwise the product's listed
// value. v0.3 will introduce per-policy currency overrides;
// for now policies inherit the product's currency.
let listed = chosen_policy
.as_ref()
.and_then(|p| p.price_sats_override) // legacy column; may carry override in fiat units after admin UI lands
.unwrap_or(product.price_value);
let conversion =
crate::rates::convert_to_sats(&state, &product.price_currency, listed)
.await
.map_err(|e| AppError::Upstream(format!("rate fetch failed: {e:#}")))?;
listed_currency = Some(product.price_currency.clone());
listed_value = Some(listed);
exchange_rate_centibps = conversion.rate_centibps;
exchange_rate_source = Some(conversion.source);
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
// result in a buyer holding a discounted live invoice for an
// already-exhausted code.
//
// step A: lookup + eligibility checks (active, expired, applies-to)
// step B: atomically increment used_count (try_reserve_code_slot)
// step C: compute discount, create BTCPay invoice
// step D: persist local invoice
// step E: insert the pending redemption row (record_pending_redemption)
//
// If C, D, or E fail after B succeeded, we call release_code_slot to
// give the slot back.
// Determine the effective discount code. If the buyer typed one,
// honor it (operator-typed beats auto-applied). Otherwise, look up
// the most applicable active featured discount for the chosen
// policy and use it. This is the "launch special" auto-apply
// path — operators can run a public promo without making buyers
// type the code.
let explicit_code: Option<String> = req
.code
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string());
let effective_code: Option<String> = if explicit_code.is_some() {
explicit_code
} else if let Some(pol) = chosen_policy.as_ref() {
repo::find_applicable_featured_discount(&state.db, &product.id, &pol.id)
.await?
.map(|c| c.code)
} else {
None
};
let (final_price, reservation, discount_applied) = if let Some(raw_code) =
effective_code.as_deref().filter(|s| !s.trim().is_empty())
{
let code = repo::get_discount_code_by_code(&state.db, raw_code)
.await?
.ok_or_else(|| AppError::BadRequest("unknown discount code".into()))?;
if !code.active {
return Err(AppError::BadRequest("discount code is disabled".into()));
}
if let Some(exp) = &code.expires_at {
if let Ok(when) = chrono::DateTime::parse_from_rfc3339(exp) {
if when.with_timezone(&chrono::Utc) < chrono::Utc::now() {
return Err(AppError::BadRequest("discount code has expired".into()));
}
}
}
if let Some(pid) = &code.applies_to_product_id {
if pid != &product.id {
return Err(AppError::BadRequest(
"discount code does not apply to this product".into(),
));
}
}
// If the code is restricted to one or more policies and a tier
// was selected, the chosen tier must be in the allowed set.
// `allowed_policy_ids()` unifies the multi-policy column (0018)
// and the legacy singular column. 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+).
let allowed = code.allowed_policy_ids();
if !allowed.is_empty() {
if let Some(chosen) = &chosen_policy {
if !allowed.iter().any(|p| *p == 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?;
let discount = compute_discount(&code.kind, code.amount, base_price);
let final_price = (base_price - discount).max(MIN_INVOICE_SATS);
(final_price, Some(code), discount)
} else {
(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.
let internal_id = uuid::Uuid::new_v4().to_string();
// If the caller didn't supply a redirect_url, default to our own
// /thank-you page with the invoice id baked in. After payment
// BTCPay sends the buyer's browser there; the page polls
// /v1/purchase/<invoice_id> until the license is issued, then
// renders it. Internal ID (UUID) goes in the URL so the buyer can
// bookmark it / refresh later if they close the tab.
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_id
);
let redirect_url = req
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&default_redirect);
// Step C: provider-agnostic invoice creation. The trait method
// handles provider-specific concerns (HMAC-headered request, URL
// rewriting from internal hostname to public, metadata enrichment
// with `orderId`/`source`) inside its impl, so this code path is
// identical for any future provider (Zaprite, etc.). On failure,
// release the slot and bail.
let provider = match state.payment_provider().await {
Ok(p) => p,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
let created = match provider
.create_invoice(CreateInvoiceParams {
amount: Money::sats(final_price),
redirect_url,
// We pass `productId` through for any provider that exposes
// it on its dashboard / receipt. The trait's enrichment
// adds `orderId` (= our internal_id) and `source` so
// webhooks can be correlated to the local invoice row.
metadata: json!({ "productId": product.id }),
external_order_id: &internal_id,
buyer_email: req.buyer_email.as_deref(),
})
.await
{
Ok(handle) => handle,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(AppError::Upstream(format!(
"payment provider create-invoice failed: {e}"
)));
}
};
let checkout_url = created.checkout_url.clone();
// Step D: persist local invoice. On failure, release the slot.
// Use internal_id we pre-generated (and baked into the BTCPay
// redirect_url) as the local row id so /v1/purchase/<id> and
// /thank-you?invoice_id=<id> all resolve to the same row.
let invoice = match repo::create_invoice_with_currency(
&state.db,
&internal_id,
&created.provider_invoice_id,
&product.id,
final_price,
&checkout_url,
req.buyer_email.as_deref(),
req.buyer_note.as_deref(),
chosen_policy.as_ref().map(|p| p.id.as_str()),
listed_currency.as_deref(),
listed_value,
exchange_rate_centibps,
exchange_rate_source.as_deref(),
)
.await
{
Ok(inv) => inv,
Err(e) => {
if let Some(code) = &reservation {
let _ = repo::release_code_slot(&state.db, &code.id).await;
}
return Err(e);
}
};
// Step E: persist the redemption row tying the slot to the invoice.
if let Some(code) = &reservation {
if let Err(e) = repo::record_pending_redemption(
&state.db,
&code.id,
&invoice.id,
discount_applied,
base_price,
final_price,
)
.await
{
// Slot was reserved but we couldn't record the redemption.
// Release the slot and mark the BTCPay invoice as invalid
// locally so we don't accidentally honour it on settle.
tracing::error!(
code = %code.code,
invoice_id = %invoice.id,
error = %e,
"failed to persist pending redemption; releasing slot \
and invalidating local invoice"
);
let _ = repo::release_code_slot(&state.db, &code.id).await;
let _ = repo::update_invoice_status(&state.db, &created.provider_invoice_id, "invalid").await;
return Err(e);
}
}
let poll_url = format!("{}/v1/purchase/{}", state.config.public_base_url, invoice.id);
Ok(Json(StartPurchaseResp {
invoice_id: invoice.id,
btcpay_invoice_id: created.provider_invoice_id,
checkout_url,
amount_sats: final_price,
base_price_sats: base_price,
discount_applied_sats: discount_applied,
poll_url,
license_key: None,
license_id: None,
}))
}
/// Apply the discount math. Returns the sats to subtract from `base`.
/// Caller is responsible for clamping the result (and for floor enforcement).
/// `pub(crate)` so the public policies endpoint can preview the post-
/// discount price on tier cards for featured (auto-applied) codes.
pub(crate) fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
match kind {
"percent" => {
// amount is basis points (0..=10000). 5000 == 50%.
// Multiply in i128 to avoid overflow on large sat amounts.
let bps = amount.clamp(0, 10_000) as i128;
let base = base_price_sats as i128;
((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,
}
}
/// Polling endpoint — returns status; if settled and a license has been
/// issued, returns the signed key string.
pub async fn status(
State(state): State<AppState>,
Path(invoice_id): Path<String>,
) -> AppResult<Json<Value>> {
let invoice = repo::get_invoice_by_id(&state.db, &invoice_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("invoice '{invoice_id}'")))?;
let license = repo::get_license_by_invoice(&state.db, &invoice.id).await?;
let license_key = match &license {
Some(lic) if lic.status == "active" => {
// Re-issue the encoded key deterministically from the stored
// license row. `issued_at` is parseable as RFC3339; we reduce to
// unix seconds. Fingerprint binding isn't done here because the
// key is still unbound at first delivery — it'll be bound the
// first time the app calls /v1/validate or /v1/machines/activate.
let flags = if lic.is_trial { FLAG_TRIAL } else { 0 };
let expires_at = 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 stored product_id: {e}"))
})?,
license_id: uuid::Uuid::parse_str(&lic.id).map_err(|e| {
AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}"))
})?,
issued_at: chrono::DateTime::parse_from_rfc3339(&lic.issued_at)
.map(|t| t.timestamp())
.unwrap_or(0),
expires_at,
fingerprint_hash: [0u8; 32],
entitlements: lic.entitlements.clone(),
};
let sig = sign_payload(&state.keypair.signing, &payload);
Some(encode_key(&payload, &sig))
}
_ => None,
};
Ok(Json(json!({
"invoice_id": invoice.id,
"status": invoice.status,
"product_id": invoice.product_id,
"amount_sats": invoice.amount_sats,
"license_key": license_key,
"license_id": license.as_ref().map(|l| l.id.clone()),
})))
}