Migrate reconcile + tipping onto PaymentProvider trait; add worker tests

Two compat-path holdovers migrated:

- src/reconcile.rs: was state.btcpay_client().get_invoice() with
  manual JSON parsing of BTCPay-specific status strings ("Settled",
  "Complete", "Expired", "Invalid"). Now state.payment_provider()
  .get_invoice_status() returning the typed ProviderInvoiceStatus
  enum. The string normalization moves into BtcpayProvider's impl
  where it belongs.

- src/tipping.rs: was state.btcpay_client().pay_lightning_invoice()
  returning raw JSON, then manual paymentHash extraction. Now
  provider.pay_lightning_invoice() returning a typed PaymentReceipt
  { payment_hash, raw }. The audit message now records the active
  provider's kind() rather than hardcoding "BTCPay LN node".

Combined with v0.1.0:43's purchase migration, the daemon's
non-test code now contains zero calls to state.btcpay_client() or
.btcpay_webhook_secret(). Those compat accessors stay on AppState
for v0.2 (no need to break things gratuitously) but they're dead
code in the production path. Zaprite's drop-in only needs to
implement the trait.

Worker integration tests (tests/worker.rs):

- worker_marks_failure_and_schedules_retry_on_500: spins up a tiny
  axum receiver that 500s, calls webhooks::tick(), verifies attempt
  count and next-attempt scheduling.
- worker_dead_letters_after_max_attempts: seeds a row at attempt
  count 9, ticks once, verifies attempt_count → 10 and
  next_attempt_at → NULL. Confirms the row also satisfies the admin
  DLQ predicate (the contract :43's webhook_deliveries.rs depends
  on).
- worker_marks_success_on_2xx: pins the happy path.

webhooks::tick is now `pub` so integration tests can drive it
synchronously.

Test count: 26 (9 unit + 4 migration + 10 API + 3 worker).
This commit is contained in:
Grant
2026-05-08 10:40:11 -05:00
parent 96490bf3bf
commit 5ec9a6e8c0
4 changed files with 291 additions and 30 deletions
+16 -16
View File
@@ -41,8 +41,12 @@ pub fn spawn(state: AppState) {
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
// 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.
let provider = match state.payment_provider().await {
Ok(p) => p,
Err(_) => return Ok(()), // not configured yet — skip silently
};
@@ -56,21 +60,17 @@ async fn tick(state: &AppState) -> anyhow::Result<()> {
tracing::debug!(count = pending.len(), "reconciling pending invoices");
for inv in pending {
match btcpay.get_invoice(&inv.btcpay_invoice_id).await {
Ok(raw) => {
let status_str = raw
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let normalized = match status_str.as_str() {
"Settled" | "Complete" => Some("settled"),
"Expired" => Some("expired"),
"Invalid" => Some("invalid"),
// still in flight
_ => None,
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,
};
let Some(new_status) = normalized else { continue };
if new_status == inv.status.as_str() {
continue; // no-op
+17 -13
View File
@@ -199,11 +199,15 @@ async fn run_tip(
}
};
// Pay it via the operator's BTCPay Lightning node.
let btcpay = match state.btcpay_client().await {
Ok(c) => c,
// 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 {
Ok(p) => p,
Err(e) => {
let detail = format!("BTCPay client unavailable: {e:?}");
let detail = format!("payment provider unavailable: {e:?}");
repo::record_tip_attempt(
&state.db,
license_id,
@@ -222,17 +226,13 @@ async fn run_tip(
}
};
match btcpay.pay_lightning_invoice(&invoice).await {
Ok(payment) => {
let payment_hash = payment
.get("paymentHash")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match provider.pay_lightning_invoice(&invoice).await {
Ok(receipt) => {
tracing::info!(
license = %license_id,
recipient = %recipient,
amount_sats = tip_sats,
payment_hash = ?payment_hash,
payment_hash = ?receipt.payment_hash,
"tip sent"
);
repo::record_tip_attempt(
@@ -244,8 +244,12 @@ async fn run_tip(
pct,
label.as_deref(),
"sent",
Some(&format!("paid via BTCPay LN node ({} sats)", tip_sats)),
payment_hash.as_deref(),
Some(&format!(
"paid via {} LN node ({} sats)",
provider.kind().as_str(),
tip_sats
)),
receipt.payment_hash.as_deref(),
)
.await
.ok();
+5 -1
View File
@@ -91,7 +91,11 @@ pub fn spawn_delivery_worker(state: AppState) {
});
}
async fn tick(state: &AppState) -> anyhow::Result<()> {
/// Process up to 25 due deliveries: HMAC-sign, POST, mark each
/// success/failure. Public so integration tests can drive the worker
/// synchronously (the spawned background task in
/// `spawn_delivery_worker` simply calls this every 5s).
pub async fn tick(state: &AppState) -> anyhow::Result<()> {
let due = repo::list_ready_deliveries(&state.db, 25)
.await
.map_err(|e| anyhow::anyhow!("{e:?}"))?;