Files
keysat/licensing-service/src/api/purchase.rs
T
Grant 7c4dfbacd2 WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.

migrations/0021_invoice_provider_link.sql (new)
  Adds invoices.payment_provider_id (nullable FK), backfills existing
  pending/settled rows to the earliest-connected provider on the default
  profile. Additive — no drops, no removals. Companion to 0020 from the
  foundation commit.

models.rs
  Invoice gains payment_provider_id: Option<String>.

db/repo.rs
  row_to_invoice reads the new column. All three invoice SELECTs include
  it. create_invoice + create_invoice_with_currency take a new optional
  payment_provider_id parameter and persist it on INSERT.

subscriptions.rs
  Subscription struct gains merchant_profile_id + payment_provider_id
  (snapshotted on create). SUB_COLS + row_to_subscription + the manual
  SELECT in find_lapsing_subscriptions all updated. create_subscription
  accepts both new fields and writes them on the INSERT row.

  renew_one — reads the sub's payment_provider_id snapshot and resolves
  the provider via state.payment_provider_by_id(). Falls back to the
  legacy state.payment_provider() for any subs created pre-:52 that
  the migration backfill missed.

  capture_zaprite_payment_profile — uses the INVOICE's provider, not
  "the active one." Saved-profile ids are scoped per Zaprite org; using
  the wrong provider would fail the lookup.

  try_auto_charge_zaprite — uses the sub's snapshotted provider (same
  rationale).

reconcile.rs
  Per-invoice provider lookup. Each pending invoice is reconciled
  against state.payment_provider_by_id(inv.payment_provider_id), with
  graceful fallback for NULL provider ids. No more single-global-
  provider assumption.

tipping.rs
  Tip pay-out uses the provider that settled the license's purchase
  invoice (joined via licenses.invoice_id). Same rationale as the
  capture hook — the tip needs to draw from the right LN node.

api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
  Tier-change invoices ride on existing licenses. The right provider
  is whichever the license's subscription is snapshotted to (so the
  proration charge settles to the same merchant identity that collects
  renewal fees). Falls back to the invoice's recorded provider, then
  the legacy default, for licenses with no subscription or pre-
  snapshot rows.

api/purchase.rs
  StartPurchaseReq gains an optional `rail` field
  ("lightning"/"onchain"/"card") for the future buy-page multi-rail
  picker. When omitted (today's behavior), the daemon picks the first
  rail the product's merchant profile exposes — which is correct for
  single-provider operators AND back-compat for any pre-:52 client
  not yet sending the field.

  Provider resolution: product → merchant_profile → rail →
  resolve_provider_for_profile_rail. The redirect_url defaults to the
  profile's post_purchase_redirect_url (with {invoice_id} substitution)
  if set, else Keysat's own /thank-you. New invoices carry their
  provider's id via the new create_invoice_with_currency parameter.

api/webhook.rs
  issue_license_for_invoice now passes snapshot fields when calling
  subscriptions::create_subscription — both merchant_profile_id (from
  product lookup) and payment_provider_id (from the invoice row).

main.rs
  Replaces the legacy "active provider preference" boot loader with a
  default-profile-first-provider warm-up. The legacy state.payment
  singleton stays populated for back-compat with call sites that
  haven't yet migrated to the on-demand resolution path. Pre-migration
  fallback to the old singleton-config loaders preserved so the
  daemon still boots cleanly on a DB that hasn't run 0020 yet.

Remaining for part 3:
  - BTCPay + Zaprite connect flows take merchant_profile_id and
    INSERT into payment_providers (currently still write to the
    dropped singleton tables, broken post-migration).
  - api/payment_provider.rs activate endpoint becomes irrelevant in
    the new model — repurpose as list-providers, or delete.
  - Thank-you page (api/mod.rs) provider-kind lookup ports to the
    invoice's recorded provider.
  - Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
  - Admin UI for Merchant Profiles + product picker + buy-page brand
    block + rail picker.
  - Tier-cap wire-up for unlimited_merchant_profiles entitlement.
  - Version bump to :52 + release notes.

Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:26:22 -05:00

740 lines
31 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>,
/// 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<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();
// 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/<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(),
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<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()),
})))
}