Files
keysat/licensing-service/src/tipping.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

375 lines
12 KiB
Rust

//! Tip-recipient-on-policy: fire a Lightning tip after every successful
//! license issuance under a tip-enabled policy.
//!
//! Flow:
//! 1. License is issued (existing path; this module is called from the
//! reconcile/webhook layer once that completes).
//! 2. Look up the policy. If `tip_recipient` is set and `tip_pct_bps > 0`,
//! compute `amount_sats = paid_sats * tip_pct_bps / 10000`.
//! 3. Resolve the Lightning Address. We support exactly the Lightning
//! Address scheme `user@domain`, which maps to
//! `https://domain/.well-known/lnurlp/user`. Plain LNURL-pay bech32
//! strings are not supported in v0.1; can add later.
//! 4. Fetch the LNURL-pay metadata, verify the amount fits in
//! `[minSendable, maxSendable]`, request a BOLT11 invoice for our
//! amount via the `callback` URL.
//! 5. Pay the BOLT11 via the operator's BTCPay Lightning node.
//! 6. Record success/failure in the `tip_attempts` audit table.
//!
//! Failure semantics: this module **never** propagates errors back to the
//! issuance path. A tip failing is a logged + audited concern, not a reason
//! to fail a customer's purchase. Operators set up tipping voluntarily;
//! they accept the trade-off that an occasional tip will fail and can be
//! retried manually.
use crate::api::AppState;
use crate::db::repo;
use crate::models::Policy;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
/// Maximum amount in millisats we'll send via a single tip. Defense in
/// depth — a misconfigured `tip_pct_bps` shouldn't be able to drain the
/// wallet on a single sale.
const MAX_TIP_MSAT: u64 = 5_000_000_000; // 50,000,000 sats; 0.5 BTC
#[derive(Debug, Deserialize)]
struct LnurlPayMetadata {
callback: String,
#[serde(rename = "minSendable")]
min_sendable: u64,
#[serde(rename = "maxSendable")]
max_sendable: u64,
#[serde(default)]
tag: String,
}
#[derive(Debug, Deserialize)]
struct LnurlPayInvoice {
pr: String, // BOLT11
}
/// Spawn a tip in the background. Caller fires this after issuance and
/// returns immediately — the customer's purchase response doesn't wait for
/// the tip to complete.
pub fn spawn_tip(
state: AppState,
license_id: String,
policy: Policy,
paid_sats: i64,
) {
tokio::spawn(async move {
if let Err(e) = run_tip(&state, &license_id, &policy, paid_sats).await {
tracing::warn!(
license = %license_id,
policy = %policy.id,
"tip flow ended with error: {e:#}"
);
// run_tip records its own audit entries; this is just the catch-all log.
}
});
}
async fn run_tip(
state: &AppState,
license_id: &str,
policy: &Policy,
paid_sats: i64,
) -> Result<()> {
let recipient = match &policy.tip_recipient {
Some(r) if !r.trim().is_empty() => r.trim().to_string(),
_ => return Ok(()), // no tip configured; not an error
};
let pct = policy.tip_pct_bps;
if pct <= 0 {
return Ok(());
}
let label = policy.tip_label.clone();
// Compute tip amount. Round down (floor); we never tip more than the
// configured percentage of what the buyer paid.
let tip_sats = paid_sats.saturating_mul(pct) / 10_000;
if tip_sats <= 0 {
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
0,
pct,
label.as_deref(),
"skipped",
Some("tip_sats <= 0 after percentage applied"),
None,
)
.await
.ok();
return Ok(());
}
let tip_msat = (tip_sats as u64).saturating_mul(1000);
if tip_msat > MAX_TIP_MSAT {
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"skipped",
Some(&format!(
"tip exceeds safety cap ({} msat > {} msat)",
tip_msat, MAX_TIP_MSAT
)),
None,
)
.await
.ok();
return Ok(());
}
// Resolve Lightning Address → LNURL-pay metadata.
let metadata = match resolve_lightning_address(&recipient).await {
Ok(m) => m,
Err(e) => {
let detail = format!("address resolution failed: {e:#}");
tracing::warn!(license = %license_id, recipient = %recipient, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
if tip_msat < metadata.min_sendable || tip_msat > metadata.max_sendable {
let detail = format!(
"tip amount {tip_msat} msat outside recipient bounds [{}, {}]",
metadata.min_sendable, metadata.max_sendable
);
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
// Request a BOLT11 invoice from the recipient for our amount.
let invoice = match request_lnurl_invoice(&metadata.callback, tip_msat).await {
Ok(b) => b,
Err(e) => {
let detail = format!("invoice request failed: {e:#}");
tracing::warn!(license = %license_id, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
// Pay it via the provider's LN node — same provider that settled
// this license's purchase invoice (so the tip draws from the right
// Bitcoin balance). Provider-agnostic; BTCPay implements
// `pay_lightning_invoice` today, future providers either implement
// it (Zaprite via Strike?) or fall through to the trait default
// which returns a "not supported" error that we record as a failed
// tip. Falls back to the legacy active-provider accessor if the
// license's invoice has no payment_provider_id set (pre-0021).
let invoice_provider_id: Option<String> = sqlx::query_scalar(
"SELECT i.payment_provider_id FROM invoices i \
JOIN licenses l ON l.invoice_id = i.id \
WHERE l.id = ?",
)
.bind(license_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let provider_result = match invoice_provider_id.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await,
None => state.payment_provider().await,
};
let provider = match provider_result {
Ok(p) => p,
Err(e) => {
let detail = format!("payment provider unavailable: {e:?}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
return Ok(());
}
};
match provider.pay_lightning_invoice(&invoice).await {
Ok(receipt) => {
tracing::info!(
license = %license_id,
recipient = %recipient,
amount_sats = tip_sats,
payment_hash = ?receipt.payment_hash,
"tip sent"
);
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"sent",
Some(&format!(
"paid via {} LN node ({} sats)",
provider.kind().as_str(),
tip_sats
)),
receipt.payment_hash.as_deref(),
)
.await
.ok();
}
Err(e) => {
let detail = format!("BTCPay pay-LN-invoice failed: {e:#}");
tracing::warn!(license = %license_id, "{detail}");
repo::record_tip_attempt(
&state.db,
license_id,
&policy.id,
&recipient,
tip_sats,
pct,
label.as_deref(),
"failed",
Some(&detail),
None,
)
.await
.ok();
}
}
Ok(())
}
/// Parse `user@domain` and fetch the LNURL-pay metadata document at
/// `https://domain/.well-known/lnurlp/user`. Returns the parsed metadata.
async fn resolve_lightning_address(addr: &str) -> Result<LnurlPayMetadata> {
let (user, domain) = addr
.split_once('@')
.ok_or_else(|| anyhow!("not a Lightning Address (expected user@domain)"))?;
if user.is_empty() || domain.is_empty() {
bail!("Lightning Address has empty user or domain");
}
// Reasonable charset check — LN addresses are user-input-safe alphanum + dash + underscore + dot.
let charset_ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
if !user.chars().all(charset_ok) || !domain.chars().all(charset_ok) {
bail!("Lightning Address contains disallowed characters");
}
let url = format!("https://{domain}/.well-known/lnurlp/{user}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building HTTP client")?;
let resp = client.get(&url).send().await.context("LNURL-pay GET")?;
if !resp.status().is_success() {
bail!("LNURL-pay endpoint returned {}", resp.status());
}
let metadata: LnurlPayMetadata = resp
.json()
.await
.context("parsing LNURL-pay metadata response")?;
if !metadata.tag.is_empty() && metadata.tag != "payRequest" {
bail!(
"expected LNURL-pay metadata tag='payRequest', got '{}'",
metadata.tag
);
}
if !metadata.callback.starts_with("https://") {
bail!(
"LNURL-pay callback must be HTTPS, got: {}",
metadata.callback
);
}
Ok(metadata)
}
/// Hit the recipient's `callback` URL with `?amount=<msat>` and return the
/// resulting BOLT11 invoice string.
async fn request_lnurl_invoice(callback: &str, amount_msat: u64) -> Result<String> {
let sep = if callback.contains('?') { '&' } else { '?' };
let url = format!("{callback}{sep}amount={amount_msat}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building HTTP client")?;
let resp = client.get(&url).send().await.context("LNURL-pay invoice GET")?;
if !resp.status().is_success() {
bail!(
"LNURL-pay invoice endpoint returned {}",
resp.status()
);
}
// The response can be either { pr, ... } on success or
// { status: "ERROR", reason: "..." } on failure.
let body: serde_json::Value = resp
.json()
.await
.context("parsing LNURL-pay invoice response")?;
if let Some("ERROR") = body.get("status").and_then(|s| s.as_str()) {
let reason = body
.get("reason")
.and_then(|s| s.as_str())
.unwrap_or("unknown");
bail!("LNURL-pay invoice error: {reason}");
}
let parsed: LnurlPayInvoice = serde_json::from_value(body)
.context("LNURL-pay response missing 'pr' field")?;
Ok(parsed.pr)
}