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:
@@ -0,0 +1,48 @@
|
||||
-- Link invoices to the payment provider that created them.
|
||||
--
|
||||
-- Companion to migration 0020 (merchant profiles + multi-provider). With a
|
||||
-- single active provider, the reconciler could just iterate pending
|
||||
-- invoices and call `provider.get_invoice_status()` on every one — every
|
||||
-- invoice was implicitly from the only configured provider. With
|
||||
-- N providers per profile and M profiles per Keysat instance, that
|
||||
-- assumption breaks: each invoice needs to record WHICH provider it was
|
||||
-- created against so the reconciler can dispatch to the right
|
||||
-- `get_invoice_status()` and the webhook handler can validate against
|
||||
-- the right secret.
|
||||
--
|
||||
-- Additive: nullable column + index. Backfill points every pre-migration
|
||||
-- invoice at whatever provider was active when 0020 ran (same heuristic
|
||||
-- the subscriptions backfill uses — earliest-connected on the default
|
||||
-- profile). Post-migration, `repo::create_invoice_with_currency` always
|
||||
-- writes the provider id.
|
||||
--
|
||||
-- Why not part of 0020: 0020 has shipped to the master operator's git
|
||||
-- history (commit 04e0dcd) but not yet been *applied* to any DB (the
|
||||
-- master box is still on :51, which has neither migration). The append-
|
||||
-- only convention for migrations is the safer pattern even when we could
|
||||
-- technically still rewrite 0020 — keeps the sqlx migration hashes
|
||||
-- stable for anyone who ever runs an intermediate WIP build.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_provider
|
||||
ON invoices(payment_provider_id);
|
||||
|
||||
-- Backfill existing pending/settled invoices to point at the provider
|
||||
-- that was active when 0020 ran. Heuristic: pick the provider on the
|
||||
-- default merchant profile whose kind matches the (now-removed)
|
||||
-- active_payment_provider setting if it existed pre-0020; else the
|
||||
-- earliest-connected provider on the default profile. Mirrors the
|
||||
-- backfill logic in 0020's UPDATE subscriptions block — same merchant
|
||||
-- identity, same provider, deterministic across re-runs.
|
||||
UPDATE invoices
|
||||
SET payment_provider_id = (
|
||||
SELECT id FROM payment_providers
|
||||
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
|
||||
ORDER BY connected_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE payment_provider_id IS NULL;
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -355,6 +355,7 @@ pub async fn create_invoice(
|
||||
buyer_email: Option<&str>,
|
||||
buyer_note: Option<&str>,
|
||||
policy_id: Option<&str>,
|
||||
payment_provider_id: Option<&str>,
|
||||
) -> AppResult<Invoice> {
|
||||
create_invoice_with_currency(
|
||||
pool,
|
||||
@@ -370,6 +371,7 @@ pub async fn create_invoice(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
payment_provider_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -395,6 +397,7 @@ pub async fn create_invoice_with_currency(
|
||||
listed_value: Option<i64>,
|
||||
exchange_rate_centibps: Option<i64>,
|
||||
exchange_rate_source: Option<&str>,
|
||||
payment_provider_id: Option<&str>,
|
||||
) -> AppResult<Invoice> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
@@ -402,8 +405,9 @@ pub async fn create_invoice_with_currency(
|
||||
(id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, policy_id,
|
||||
listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source,
|
||||
payment_provider_id,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(btcpay_invoice_id)
|
||||
@@ -417,6 +421,7 @@ pub async fn create_invoice_with_currency(
|
||||
.bind(listed_value)
|
||||
.bind(exchange_rate_centibps)
|
||||
.bind(exchange_rate_source)
|
||||
.bind(payment_provider_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -466,7 +471,8 @@ pub async fn create_free_invoice(
|
||||
pub async fn get_invoice_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Invoice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id,
|
||||
listed_currency, listed_value, payment_provider_id
|
||||
FROM invoices WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -481,7 +487,8 @@ pub async fn get_invoice_by_btcpay_id(
|
||||
) -> AppResult<Option<Invoice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id,
|
||||
listed_currency, listed_value, payment_provider_id
|
||||
FROM invoices WHERE btcpay_invoice_id = ?",
|
||||
)
|
||||
.bind(btcpay_invoice_id)
|
||||
@@ -517,7 +524,8 @@ pub async fn list_pending_invoices(
|
||||
let cutoff = (Utc::now() - chrono::Duration::hours(max_age_hours)).to_rfc3339();
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, btcpay_invoice_id, product_id, status, buyer_email, buyer_note,
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id
|
||||
amount_sats, checkout_url, created_at, updated_at, policy_id,
|
||||
listed_currency, listed_value, payment_provider_id
|
||||
FROM invoices
|
||||
WHERE status = 'pending' AND created_at >= ?
|
||||
ORDER BY created_at ASC",
|
||||
@@ -546,6 +554,10 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
|
||||
.ok()
|
||||
.flatten(),
|
||||
listed_value: row.try_get::<Option<i64>, _>("listed_value").ok().flatten(),
|
||||
payment_provider_id: row
|
||||
.try_get::<Option<String>, _>("payment_provider_id")
|
||||
.ok()
|
||||
.flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,61 +57,70 @@ async fn main() -> anyhow::Result<()> {
|
||||
keypair.public_key_pem.trim()
|
||||
);
|
||||
|
||||
// --- payment provider (may be None until operator connects) ---
|
||||
// Resolution order:
|
||||
// 1. operator's explicit preference from the
|
||||
// active_payment_provider setting (set by the most recent
|
||||
// Connect or Activate action),
|
||||
// 2. fallback for legacy installs without the setting:
|
||||
// BTCPay first, Zaprite second. Once we ship v0.3 with the
|
||||
// multi-provider routing layer this fallback retires.
|
||||
let preferred = payment::read_active_provider_preference(&pool).await;
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> = match preferred {
|
||||
Some(payment::ProviderKind::Zaprite) => {
|
||||
// Operator explicitly chose Zaprite. Try Zaprite; if it
|
||||
// can't be loaded (e.g., the row was deleted out from
|
||||
// under the setting), fall through to BTCPay rather
|
||||
// than booting unconfigured.
|
||||
load_zaprite_provider(&pool)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>)
|
||||
.or_else(|| {
|
||||
// --- payment provider boot-time warm-up ---
|
||||
//
|
||||
// With the multi-merchant-profile model (migration 0020+) we no longer
|
||||
// load a single "active" provider at boot. Providers are looked up by
|
||||
// id on demand via `AppState::payment_provider_by_id` (which builds
|
||||
// from a `payment_providers` row each time it's called) and resolved
|
||||
// per purchase via `resolve_provider_for_product_rail`.
|
||||
//
|
||||
// For back-compat we still populate the legacy `state.payment`
|
||||
// singleton with the FIRST provider attached to the default merchant
|
||||
// profile — this is what `state.payment_provider()` returns to the
|
||||
// remaining legacy call sites (and is a sensible fallback for any
|
||||
// code path that runs before the operator has linked a product to a
|
||||
// specific profile). Empty profile → empty singleton; the on-demand
|
||||
// resolution layer takes over from there.
|
||||
let provider: Option<Arc<dyn payment::PaymentProvider>> = match keysat::db::repo::get_default_merchant_profile(&pool).await {
|
||||
Ok(Some(profile)) => match keysat::db::repo::list_payment_providers_for_profile(&pool, &profile.id).await {
|
||||
Ok(rows) => match rows.first() {
|
||||
Some(row) => match payment::build_provider(row, cfg.btcpay_public_url.as_deref()) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"active_payment_provider=zaprite but zaprite_config is missing; \
|
||||
falling back to BTCPay"
|
||||
provider_id = %row.id,
|
||||
error = %e,
|
||||
"failed to build provider from default-profile row; \
|
||||
leaving legacy state.payment empty"
|
||||
);
|
||||
None
|
||||
})
|
||||
.or(load_btcpay_provider(&pool, &cfg)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>))
|
||||
}
|
||||
Some(payment::ProviderKind::Btcpay) | None => {
|
||||
// Either operator chose BTCPay, or no preference recorded
|
||||
// yet (legacy install). Either way, BTCPay wins if
|
||||
// configured; Zaprite as fallback.
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
profile_id = %profile.id,
|
||||
error = %e,
|
||||
"failed to list providers on default profile at boot"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
// Pre-migration: no default profile exists yet (operator hasn't
|
||||
// installed :52 yet). Fall back to the legacy singleton-config
|
||||
// loaders so the daemon still boots cleanly during the upgrade
|
||||
// window — these run against btcpay_config / zaprite_config
|
||||
// until migration 0020 drops those tables.
|
||||
load_btcpay_provider(&pool, &cfg)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>)
|
||||
.or_else(|| {
|
||||
if preferred == Some(payment::ProviderKind::Btcpay) {
|
||||
tracing::warn!(
|
||||
"active_payment_provider=btcpay but btcpay_config is missing; \
|
||||
falling back to Zaprite"
|
||||
);
|
||||
}
|
||||
None
|
||||
})
|
||||
.or(load_zaprite_provider(&pool)
|
||||
.await
|
||||
.map(|p| Arc::new(p) as Arc<dyn payment::PaymentProvider>))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read default merchant profile at boot");
|
||||
None
|
||||
}
|
||||
};
|
||||
match &provider {
|
||||
Some(p) => tracing::info!(provider = p.kind().as_str(), "payment provider connected"),
|
||||
Some(p) => tracing::info!(provider = p.kind().as_str(), "default payment provider warmed up"),
|
||||
None => tracing::warn!(
|
||||
"no payment provider yet configured — purchases will return 503 until the \
|
||||
operator completes the 'Connect BTCPay' or 'Connect Zaprite' flow"
|
||||
operator completes 'Connect BTCPay' or 'Connect Zaprite' in the admin UI"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,13 @@ pub struct Invoice {
|
||||
/// `amount_sats` (which is correct for SAT-priced products).
|
||||
#[serde(default)]
|
||||
pub listed_value: Option<i64>,
|
||||
/// Which payment provider settled this invoice. Added by migration
|
||||
/// 0021 alongside the multi-merchant-profile work; NULL on pre-0021
|
||||
/// invoices (backfilled by the migration to the first provider on
|
||||
/// the default profile). Required for reconcile.rs to dispatch
|
||||
/// status checks to the right provider when multiple are configured.
|
||||
#[serde(default)]
|
||||
pub payment_provider_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -45,11 +45,15 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
// provider-specific status-string normalization (BTCPay's
|
||||
// "Settled"/"Complete"/"Expired"/"Invalid" → ProviderInvoiceStatus
|
||||
// enum); this loop just operates on the typed result.
|
||||
let provider = match state.payment_provider().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()), // not configured yet — skip silently
|
||||
};
|
||||
|
||||
//
|
||||
// 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:?}"))?;
|
||||
@@ -60,6 +64,24 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
|
||||
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::*;
|
||||
|
||||
@@ -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,17 +1140,40 @@ 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 {
|
||||
// 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 — skipping"
|
||||
"capture: no active payment provider AND invoice has no \
|
||||
payment_provider_id — skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if provider.kind() != ProviderKind::Zaprite {
|
||||
tracing::info!(
|
||||
@@ -1313,10 +1373,19 @@ async fn try_auto_charge_zaprite(
|
||||
_ => return Ok(false),
|
||||
};
|
||||
|
||||
let provider = state
|
||||
// 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:#}"))?;
|
||||
.map_err(|e| anyhow!("payment provider unavailable: {e:#}"))?,
|
||||
};
|
||||
if provider.kind() != ProviderKind::Zaprite {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -199,12 +199,29 @@ async fn run_tip(
|
||||
}
|
||||
};
|
||||
|
||||
// Pay it via the active provider's LN node. 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.
|
||||
let provider = match state.payment_provider().await {
|
||||
// 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:?}");
|
||||
|
||||
Reference in New Issue
Block a user