Confirm settle with provider API before issuing; add test-injection seam

The settle-webhook honored payment on the webhook body's claim alone.
Zaprite webhooks carry no signature, so a forged order.change/status=PAID
POST with a buyer-visible order id minted a signed license without payment.

handle_inner now re-fetches provider.get_invoice_status and requires Settled
before persisting "settled" or taking any settle-derived action (issuance,
tier-change, subscription renewal — the guard precedes all of them). On a
provider-API error it acks 200 without issuing, so a transient outage can't
trigger a webhook retry storm; the reconcile loop re-confirms and issues later.

Adds the always-compiled AppState::provider_override seam (None in prod),
honored by provider_from_row at every resolution site, so integration tests
drive the real resolver with a MockPaymentProvider. Greens the two
paid_purchase_* tests, deletes the dead payment_provider_preference_round_trip,
and adds forged-settle + provider-unreachable regression tests. api 47/47.

Not addressed: a literal paid-amount/currency check (needs a trait change).
This commit is contained in:
Grant
2026-06-12 22:36:42 -05:00
parent 8c4baccf6b
commit 783372c03b
7 changed files with 301 additions and 174 deletions
+26 -10
View File
@@ -108,6 +108,15 @@ pub struct AppState {
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
/// write lock when the operator runs Connect / Disconnect.
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
/// Test-only injection seam. When `Some`, the merchant-profile
/// resolver (`resolve_provider_for_profile_rail`, `payment_provider_by_id`)
/// returns THIS provider instead of constructing a real BTCPay/Zaprite
/// client from the DB row via `payment::build_provider`. The DB still
/// drives profile/rail/row resolution, so that logic is exercised for
/// real — only the network-talking impl is swapped. Always `None` in
/// production (`main.rs`); set by integration tests so they can drive
/// the real purchase/settle path with a `MockPaymentProvider`.
pub provider_override: Option<Arc<dyn crate::payment::PaymentProvider>>,
pub config: Arc<Config>,
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
/// operator activates a fresh license via the admin endpoint.
@@ -199,7 +208,20 @@ impl AppState {
.ok_or_else(|| {
AppError::NotFound(format!("payment provider {provider_id}"))
})?;
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
self.provider_from_row(&row)
}
/// Instantiate a `PaymentProvider` from a resolved DB row, honoring the
/// test-only `provider_override` seam. In production `provider_override`
/// is always `None`, so this just delegates to `payment::build_provider`.
fn provider_from_row(
&self,
row: &crate::db::repo::PaymentProviderRow,
) -> AppResult<Arc<dyn crate::payment::PaymentProvider>> {
if let Some(p) = &self.provider_override {
return Ok(p.clone());
}
crate::payment::build_provider(row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)
}
@@ -241,9 +263,7 @@ impl AppState {
pref.payment_provider_id
))
})?;
let provider =
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)?;
let provider = self.provider_from_row(&row)?;
return Ok((row, provider));
}
@@ -271,9 +291,7 @@ impl AppState {
))),
[only] => {
let row = (*only).clone();
let provider =
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)?;
let provider = self.provider_from_row(&row)?;
Ok((row, provider))
}
[first, ..] => {
@@ -287,9 +305,7 @@ impl AppState {
this warning."
);
let row = (*first).clone();
let provider =
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
.map_err(AppError::Internal)?;
let provider = self.provider_from_row(&row)?;
Ok((row, provider))
}
}