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
+93 -24
View File
@@ -122,6 +122,19 @@ pub struct Subscription {
/// an `/v1/orders/charge` failure and fall through to the
/// manual-pay branch.
pub zaprite_payment_profile_expires_at: Option<String>,
/// Merchant profile the subscription was attached to at
/// creation. Frozen for the lifetime of the sub so an operator
/// editing the product's profile attachment doesn't redirect
/// existing buyers to a different business mid-cycle. NULL on
/// subs created pre-migration 0020 (backfilled to the default
/// profile during the migration).
pub merchant_profile_id: Option<String>,
/// Payment provider used for THIS subscription's billing cycle.
/// Frozen at creation (same rationale as merchant_profile_id).
/// The renewal worker uses this to look up the provider — it
/// never re-resolves from the product (which might have moved
/// to a different profile / different providers).
pub payment_provider_id: Option<String>,
}
fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
@@ -144,6 +157,8 @@ fn row_to_subscription(row: sqlx::sqlite::SqliteRow) -> Subscription {
zaprite_payment_profile_expires_at: row
.try_get("zaprite_payment_profile_expires_at")
.ok(),
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
payment_provider_id: row.try_get("payment_provider_id").ok().flatten(),
}
}
@@ -151,7 +166,8 @@ const SUB_COLS: &str = "id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
cancelled_at, consecutive_failures, \
zaprite_contact_id, zaprite_payment_profile_id, \
zaprite_payment_profile_method, zaprite_payment_profile_expires_at";
zaprite_payment_profile_method, zaprite_payment_profile_expires_at, \
merchant_profile_id, payment_provider_id";
/// Subs that are due for the worker to act on right now: status
/// is `active` or `past_due`, `next_renewal_at` is in the past,
@@ -196,7 +212,8 @@ pub async fn find_lapsing_subscriptions(
s.next_renewal_at, s.cancelled_at, s.consecutive_failures, \
s.zaprite_contact_id, s.zaprite_payment_profile_id, \
s.zaprite_payment_profile_method, \
s.zaprite_payment_profile_expires_at \
s.zaprite_payment_profile_expires_at, \
s.merchant_profile_id, s.payment_provider_id \
FROM subscriptions s \
JOIN policies p ON p.id = s.policy_id \
WHERE s.status = 'past_due' \
@@ -373,6 +390,8 @@ pub async fn create_subscription(
listed_currency: &str,
listed_value: i64,
first_cycle_invoice_id: &str,
merchant_profile_id: Option<&str>,
payment_provider_id: Option<&str>,
) -> Result<Subscription> {
let id = Uuid::new_v4().to_string();
let now = Utc::now();
@@ -382,8 +401,9 @@ pub async fn create_subscription(
sqlx::query(
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
consecutive_failures, created_at, updated_at) \
VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?)",
consecutive_failures, merchant_profile_id, payment_provider_id, \
created_at, updated_at) \
VALUES(?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)",
)
.bind(&id)
.bind(license_id)
@@ -394,6 +414,8 @@ pub async fn create_subscription(
.bind(listed_value)
.bind(&started_at)
.bind(&next_renewal_at)
.bind(merchant_profile_id)
.bind(payment_provider_id)
.bind(&started_at)
.bind(&started_at)
.execute(pool)
@@ -436,6 +458,8 @@ pub async fn create_subscription(
zaprite_payment_profile_id: None,
zaprite_payment_profile_method: None,
zaprite_payment_profile_expires_at: None,
merchant_profile_id: merchant_profile_id.map(|s| s.to_string()),
payment_provider_id: payment_provider_id.map(|s| s.to_string()),
})
}
@@ -696,12 +720,24 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
.context("rate conversion")?;
let amount_sats = conversion.sats.max(1);
// 2. Get the active provider. If no provider is configured
// we can't bill — surfaces as a renewal failure that
// backs off (operator probably mid-Disconnect).
let provider = state.payment_provider().await.map_err(|e| {
anyhow!("payment provider unavailable for renewal: {e:#}")
})?;
// 2. Get the provider snapshotted on this sub at creation. The
// snapshot semantics protect existing buyers from operator-side
// re-routing: if the product was moved to a different merchant
// profile or its providers changed, this sub keeps renewing
// through the same business + payment account it started with.
// Falls back to the default profile's first provider if the
// snapshot is NULL (pre-migration subs that the 0020 backfill
// missed, or any rows that slipped through with NULL).
let provider = match sub.payment_provider_id.as_deref() {
Some(pid) => state
.payment_provider_by_id(pid)
.await
.map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?,
None => state
.payment_provider()
.await
.map_err(|e| anyhow!("payment provider unavailable for renewal: {e:#}"))?,
};
// 3. Compute the next cycle window.
let now = Utc::now();
@@ -786,6 +822,7 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
} else {
Some(conversion.source.as_str())
},
None, // payment_provider_id — set when this call site is ported to the multi-provider resolution layer
)
.await
.map_err(|e: AppError| anyhow!("repo create_invoice: {e:?}"))?;
@@ -1103,16 +1140,39 @@ pub async fn capture_zaprite_payment_profile(
return Ok(());
}
// Active provider must be Zaprite for any of the rest to be
// meaningful — `as_any` downcast keeps the trait clean.
let provider = match state.payment_provider().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(
sub_id = %sub_id, error = %e,
"capture: no active payment provider — skipping"
);
return Ok(());
// The provider that settled THIS invoice — not "the active one." With
// multi-merchant-profile, the operator may have several providers
// configured across different profiles; capturing the saved profile
// has to talk to the SAME Zaprite org that the order was created
// against (saved-profile ids are scoped per org).
let provider = match invoice.payment_provider_id.as_deref() {
Some(pid) => match state.payment_provider_by_id(pid).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(
sub_id = %sub_id,
provider_id = pid,
error = %e,
"capture: invoice's provider unavailable — skipping"
);
return Ok(());
}
},
None => {
// Pre-0021 invoice with NULL provider — fall back to the legacy
// default. The 0021 backfill should have populated this column
// on the first migration run, so this branch is defensive only.
match state.payment_provider().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(
sub_id = %sub_id, error = %e,
"capture: no active payment provider AND invoice has no \
payment_provider_id — skipping"
);
return Ok(());
}
}
}
};
if provider.kind() != ProviderKind::Zaprite {
@@ -1313,10 +1373,19 @@ async fn try_auto_charge_zaprite(
_ => return Ok(false),
};
let provider = state
.payment_provider()
.await
.map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?;
// Use the provider snapshotted on the sub — saved-profile ids are
// scoped per Zaprite org, so we can't fall back to "the active
// provider" if the operator added another Zaprite provider since.
let provider = match sub.payment_provider_id.as_deref() {
Some(pid) => state
.payment_provider_by_id(pid)
.await
.map_err(|e| anyhow!("snapshotted provider {pid} unavailable: {e:#}"))?,
None => state
.payment_provider()
.await
.map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?,
};
if provider.kind() != ProviderKind::Zaprite {
return Ok(false);
}