//! 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, /// Optional free-text note from the buyer. pub buyer_note: Option, /// Optional URL the buyer should be returned to after payment. pub redirect_url: Option, /// Optional discount / referral code (case-insensitive). pub code: Option, /// 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, /// Optional payment rail the buyer picked on the buy page. One of /// `lightning` / `onchain` / `card`. When omitted, the daemon picks /// the first rail the product's merchant profile exposes — which is /// the right behavior for single-rail profiles AND back-compat for /// pre-:52 callers that don't know about rails yet. When the buyer /// is on a multi-rail profile and the buy page surfaces a picker, /// this field carries the choice. pub rail: Option, } #[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, #[serde(skip_serializing_if = "Option::is_none")] pub license_id: Option, } /// 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, Json(req): Json, ) -> AppResult> { 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 = None; let mut listed_value: Option = None; let mut exchange_rate_centibps: Option = None; let mut exchange_rate_source: Option = 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 = req .code .as_deref() .filter(|s| !s.trim().is_empty()) .map(|s| s.to_string()); let effective_code: Option = 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-" 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(); // Step B.5: resolve the merchant profile + payment provider for THIS // purchase. The product is attached to exactly one merchant profile; // the profile exposes one or more payment providers (BTCPay / Zaprite). // The buyer (or their UA) names a rail via `req.rail` if the buy page's // multi-rail picker surfaced one — otherwise we pick the first rail the // profile exposes, which is the right behavior for the common // single-rail-per-profile case. The resolution layer also returns the // provider row so we can record its id on the invoice; the renewal // worker reads that id off the snapshot when auto-charging future cycles. let merchant_profile = match state.merchant_profile_for_product(&product.id).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 requested_rail = req .rail .as_deref() .and_then(crate::payment::Rail::parse); let rail = match requested_rail { Some(r) => r, None => { // No buyer pick — collect the union of rails this profile's // providers offer and use the first. With one provider this // is its primary rail; with multiple, this is whatever the // earliest-connected provider serves first. let providers = repo::list_payment_providers_for_profile( &state.db, &merchant_profile.id, ) .await?; let first_rail = providers.iter().find_map(|row| { crate::payment::ProviderKind::parse(&row.kind) .and_then(|kind| crate::payment::rails_for_kind(kind).into_iter().next()) }); match first_rail { Some(r) => r, None => { if let Some(code) = &reservation { let _ = repo::release_code_slot(&state.db, &code.id).await; } return Err(AppError::BadRequest(format!( "merchant profile '{}' has no payment providers connected — \ buyers can't pay yet. Connect one in the admin UI.", merchant_profile.name ))); } } } }; let (provider_row, provider) = match state .resolve_provider_for_profile_rail(&merchant_profile.id, rail) .await { Ok(t) => t, Err(e) => { if let Some(code) = &reservation { let _ = repo::release_code_slot(&state.db, &code.id).await; } return Err(e); } }; // If the caller didn't supply a redirect_url, prefer the merchant // profile's configured post_purchase_redirect_url (operator's app // landing page — e.g. recaps.cc/welcome). Fall back to Keysat's own // /thank-you?invoice_id=… page if neither is set. let default_redirect = format!( "{}/thank-you?invoice_id={}", state.config.public_base_url, internal_id ); let profile_redirect = merchant_profile .post_purchase_redirect_url .as_deref() .filter(|s| !s.is_empty()) .map(|tmpl| { // Allow `{invoice_id}` substitution so operators can land // buyers on a per-purchase URL on their own app. tmpl.replace("{invoice_id}", &internal_id) }); let profile_redirect_ref = profile_redirect.as_deref(); let redirect_url = req .redirect_url .as_deref() .filter(|s| !s.is_empty()) .or(profile_redirect_ref) .unwrap_or(&default_redirect); // Recurring policy: ask the provider to prompt the buyer to // save their payment profile at checkout so the renewal worker // can later auto-charge it via `charge_order_with_profile`. // Zaprite honors this for autopay-supporting rails (Stripe card // via a connected merchant account); BTCPay has no equivalent // and silently ignores the flag. We always set this on // recurring purchases — if the buyer ends up paying with // Bitcoin / Lightning, or declines the save-card prompt at // Zaprite's checkout, no profile gets created and the post- // settle profile-capture step finds nothing. The sub then // behaves like a pre-feature recurring sub: renewals still // create fresh invoices the buyer pays manually. let allow_save_profile = chosen_policy.as_ref().map(|p| p.is_recurring).unwrap_or(false); 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(), allow_save_payment_profile: if allow_save_profile { Some(true) } else { None }, }) .await { Ok(handle) => handle, Err(e) => { if let Some(code) = &reservation { let _ = repo::release_code_slot(&state.db, &code.id).await; } // `{e:#}` (alternate format) walks the anyhow error chain so // the buy page surfaces the underlying provider error directly // — e.g. "Zaprite create_order returned HTTP 400: {...}" — // instead of just the outermost `context()` wrapper. Without // this, a failed create-invoice shows only // "ZapriteProvider.create_invoice" to the operator, and the // real cause (currency mismatch / missing payment rail / API- // key scope / Zaprite-side validation error) is hidden. We // ALSO emit an explicit tracing::error! before returning so // the same chain shows up in the daemon logs — without this // line, the provider's underlying error string is never // logged anywhere (the trait method just RETURNS the // anyhow error; only the tower trace layer fires, and it // only sees the HTTP status code, not the body). tracing::error!( product_id = %product.id, error = format!("{e:#}"), "payment provider create_invoice failed" ); 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/ and // /thank-you?invoice_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(), Some(&provider_row.id), ) .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, Path(invoice_id): Path, ) -> AppResult> { 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()), }))) }