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

205 lines
8.2 KiB
Rust

//! Invoice reconciliation background task.
//!
//! Webhooks are the primary signal from BTCPay to us — fast, push-based, and
//! authenticated with HMAC. But webhooks can be dropped (network blips, our
//! service restarting during a burst, BTCPay retry-budget exhaustion on a
//! long outage). If we only ever reacted to webhooks, a dropped settle
//! notification would mean a buyer paid and never got their license.
//!
//! Reconciliation closes that gap. Every N seconds we scan our own table
//! for invoices still in `pending` status that were created recently, ask
//! BTCPay directly what their real state is, and reconcile:
//!
//! - BTCPay says `Settled` → mark settled AND issue a license if one
//! doesn't exist yet (idempotency enforced by the UNIQUE index on
//! `licenses.invoice_id`).
//! - BTCPay says `Expired` / `Invalid` → mark accordingly, don't issue.
//! - BTCPay still says `New` / `Processing` → leave it alone.
//!
//! The task is cheap — one DB query and at most N HTTP calls per tick —
//! and bounded (we only look at invoices younger than MAX_AGE_HOURS).
use crate::api::AppState;
use crate::db::repo;
use std::time::Duration;
use tokio::time::sleep;
const TICK: Duration = Duration::from_secs(60);
const MAX_AGE_HOURS: i64 = 72;
pub fn spawn(state: AppState) {
tokio::spawn(async move {
// Small initial delay so we don't race startup logs.
sleep(Duration::from_secs(15)).await;
loop {
if let Err(e) = tick(&state).await {
tracing::warn!(error = %e, "reconciliation tick failed");
}
sleep(TICK).await;
}
});
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
// Provider-agnostic. Each provider's impl handles the
// provider-specific status-string normalization (BTCPay's
// "Settled"/"Complete"/"Expired"/"Invalid" → ProviderInvoiceStatus
// enum); this loop just operates on the typed result.
//
// With multi-provider, each pending invoice is reconciled against
// its OWN provider (recorded on the invoice row, migration 0021).
// We can't iterate against a single global provider because the
// operator may have multiple providers configured across multiple
// merchant profiles. Pre-0021 invoices that slipped through with
// a NULL provider id fall back to the legacy `payment_provider()`
// accessor (which the migration's backfill should prevent from
// ever being needed in practice).
let pending = repo::list_pending_invoices(&state.db, MAX_AGE_HOURS)
.await
.map_err(|e| anyhow::anyhow!("listing pending invoices: {e:?}"))?;
if pending.is_empty() {
return Ok(());
}
tracing::debug!(count = pending.len(), "reconciling pending invoices");
for inv in pending {
let provider = match inv.payment_provider_id.as_deref() {
Some(pid) => match state.payment_provider_by_id(pid).await {
Ok(p) => p,
Err(e) => {
tracing::debug!(
error = %e,
invoice_id = %inv.id,
provider_id = pid,
"reconciler skipping invoice — its provider is unavailable"
);
continue;
}
},
None => match state.payment_provider().await {
Ok(p) => p,
Err(_) => continue, // not configured yet — skip silently
},
};
match provider.get_invoice_status(&inv.btcpay_invoice_id).await {
Ok(status) => {
use crate::payment::ProviderInvoiceStatus::*;
let new_status = match status {
Settled => "settled",
Expired => "expired",
Invalid => "invalid",
// Pending stays pending; Refunded is a v0.3 surface
// that the webhook handler also short-circuits on.
Pending | Refunded => continue,
};
if new_status == inv.status.as_str() {
continue; // no-op
}
if let Err(e) = repo::update_invoice_status(
&state.db,
&inv.btcpay_invoice_id,
new_status,
)
.await
{
tracing::warn!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to update invoice status"
);
continue;
}
// Free any reserved discount-code slot if the invoice
// entered a terminal failure state.
if matches!(new_status, "expired" | "invalid") {
if let Ok(Some(redemption)) =
repo::get_pending_redemption_by_invoice(&state.db, &inv.id).await
{
let _ = repo::cancel_redemption(&state.db, &redemption.id).await;
}
}
if new_status == "settled" {
if let Err(e) = ensure_license(state, &inv).await {
tracing::warn!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to issue license after recovered settle"
);
} else {
tracing::info!(
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler issued license for recovered settled invoice"
);
}
}
}
Err(e) => {
tracing::debug!(
error = %e,
btcpay_invoice_id = %inv.btcpay_invoice_id,
"reconciler failed to fetch invoice from BTCPay"
);
}
}
}
Ok(())
}
async fn ensure_license(
state: &AppState,
invoice: &crate::models::Invoice,
) -> anyhow::Result<()> {
if repo::get_license_by_invoice(&state.db, &invoice.id)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?
.is_some()
{
// Even if the license already exists, the reconciler may be
// running because the webhook never delivered. In that case
// `on_invoice_settled` (which runs the Zaprite-saved-profile
// capture for recurring first-cycle subs) never fired either.
// Try the post-settle hook now — it's idempotent (early-returns
// if the sub already has a captured profile, or if the active
// provider isn't Zaprite, or if no matching profile exists on
// the contact). Without this, a subscription created via the
// reconciler path never gets its `zaprite_payment_profile_id`
// populated, and renewals fall back to manual-pay forever
// even though the saved profile is sitting on Zaprite's side.
if let Err(e) =
crate::subscriptions::on_invoice_settled(state, invoice).await
{
tracing::warn!(
error = %e,
invoice_id = %invoice.id,
"reconciler post-settle hook failed (non-fatal — license already exists)"
);
}
return Ok(());
}
crate::api::webhook::issue_license_for_invoice(state, invoice)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
// Same rationale as the early-return branch above — if the
// reconciler is running, the webhook may have missed; run the
// post-settle hook so a brand-new recurring sub also captures its
// Zaprite saved profile. issue_license_for_invoice already created
// the subscription row by this point, so on_invoice_settled can
// find it.
if let Err(e) =
crate::subscriptions::on_invoice_settled(state, invoice).await
{
tracing::warn!(
error = %e,
invoice_id = %invoice.id,
"reconciler post-settle hook failed (non-fatal — license issued ok)"
);
}
Ok(())
}