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>
This commit is contained in:
Grant
2026-06-03 22:26:22 -05:00
parent 04e0dcd591
commit 7c4dfbacd2
10 changed files with 440 additions and 108 deletions
+90 -23
View File
@@ -37,6 +37,14 @@ pub struct StartPurchaseReq {
/// 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)]
@@ -419,29 +427,16 @@ pub async fn start(
// 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 {
// 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 {
@@ -450,6 +445,77 @@ pub async fn start(
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`.
@@ -528,6 +594,7 @@ pub async fn start(
listed_value,
exchange_rate_centibps,
exchange_rate_source.as_deref(),
Some(&provider_row.id),
)
.await
{
+66 -2
View File
@@ -123,7 +123,40 @@ pub async fn start(
// Create provider invoice. Same trait method the purchase + renewal
// paths use, so any provider-specific concerns (URL rewriting,
// metadata enrichment) live inside the impl.
let provider = state.payment_provider().await?;
//
// Tier-change invoices ride on an existing license. The right provider
// is whichever one the license's existing subscription is snapshotted
// to — so the proration charge settles to the same merchant identity
// that's been collecting renewal fees. Falls back to the license's
// first-cycle invoice provider, then the legacy default, for licenses
// with no subscription (one-shot upgrades) or pre-snapshot rows.
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
&state.db, &license.id,
)
.await
.ok()
.flatten()
.and_then(|s| s.payment_provider_id);
let provider_id_for_upgrade = match snapshot_provider_id {
Some(p) => Some(p),
None => {
sqlx::query_scalar::<_, Option<String>>(
"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()
.flatten()
}
};
let provider = match provider_id_for_upgrade.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await?,
None => state.payment_provider().await?,
};
let internal_invoice_id = Uuid::new_v4().to_string();
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
@@ -175,6 +208,7 @@ pub async fn start(
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
provider_id_for_upgrade.as_deref(),
)
.await?;
@@ -456,7 +490,36 @@ pub async fn admin_change(
.map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?;
let amount_sats = conversion.sats.max(1);
let provider = state.payment_provider().await?;
// Same provider-resolution pattern as the buyer-driven tier-change
// above: prefer the license's snapshotted subscription provider so
// the admin charge settles to the same merchant identity.
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
&state.db, &license.id,
)
.await
.ok()
.flatten()
.and_then(|s| s.payment_provider_id);
let provider_id_for_upgrade = match snapshot_provider_id {
Some(p) => Some(p),
None => {
sqlx::query_scalar::<_, Option<String>>(
"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()
.flatten()
}
};
let provider = match provider_id_for_upgrade.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await?,
None => state.payment_provider().await?,
};
let internal_invoice_id = Uuid::new_v4().to_string();
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
@@ -498,6 +561,7 @@ pub async fn admin_change(
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
provider_id_for_upgrade.as_deref(),
)
.await?;
+17
View File
@@ -278,6 +278,21 @@ pub async fn issue_license_for_invoice(
.ok()
.flatten();
if existing.is_none() {
// Snapshot the merchant profile + payment provider that
// settled this purchase, so the renewal worker uses the
// SAME business + payment account on subsequent cycles
// even if the operator later moves the product to a
// different profile. Falls back to the product's
// current profile (and the invoice's recorded provider)
// when the snapshot fields aren't already on the invoice.
let snapshot_profile_id = crate::db::repo::get_merchant_profile_for_product(
&state.db, &invoice.product_id,
)
.await
.ok()
.flatten()
.map(|p| p.id);
let snapshot_provider_id = invoice.payment_provider_id.clone();
match crate::subscriptions::create_subscription(
&state.db,
&license_id,
@@ -287,6 +302,8 @@ pub async fn issue_license_for_invoice(
&listed_currency,
listed_value,
&invoice.id,
snapshot_profile_id.as_deref(),
snapshot_provider_id.as_deref(),
)
.await
{