v0.2.0:40 — discount-code slot reaper for abandoned checkouts

Eager reservation at /v1/purchase prevents code-cap races but leaked
slots if BTCPay never fired the expiry webhook. New 5-min background
reaper scans for pending redemptions tied to expired/invalid invoices
or pending invoices older than 30 min, cancels each, and decrements
used_count so the slot returns to the pool.
This commit is contained in:
Grant
2026-05-12 01:01:08 -05:00
parent 1a14b9c2e3
commit d927e4940f
3 changed files with 81 additions and 1 deletions
+49
View File
@@ -2817,6 +2817,55 @@ pub async fn delete_all_sessions(pool: &SqlitePool) -> AppResult<()> {
Ok(())
}
/// Background cleanup: find pending discount redemptions whose linked
/// invoice is either in a terminal failure state (`expired` / `invalid`)
/// OR has been sitting in `pending` past the BTCPay invoice-expiry
/// window. For each, mark the redemption `cancelled` and decrement the
/// code's `used_count` so the slot becomes available again.
///
/// Plugs two leaks:
/// (a) the `InvoiceExpired`/`InvoiceInvalid` webhook fired but the
/// inline `cancel_redemption` call inside webhook handling failed
/// (already warned at webhook.rs but the slot stays stuck), and
/// (b) the provider never delivered the expiry webhook at all
/// (network blip, daemon offline at the exact moment it fired,
/// BTCPay misconfigured webhook URL).
///
/// `stale_after_minutes` is the threshold for case (b): an invoice still
/// in `pending` whose `created_at` is older than this is treated as
/// abandoned. BTCPay's default invoice expiry is 15 min, so 30 gives a
/// comfortable buffer.
///
/// Returns the number of redemptions reaped (for logging).
pub async fn reap_stale_pending_redemptions(
pool: &SqlitePool,
stale_after_minutes: i64,
) -> AppResult<u64> {
let threshold = (Utc::now() - chrono::Duration::minutes(stale_after_minutes)).to_rfc3339();
let rows = sqlx::query(
"SELECT r.id
FROM discount_redemptions r
JOIN invoices i ON i.id = r.invoice_id
WHERE r.status = 'pending'
AND (
i.status IN ('expired', 'invalid')
OR (i.status = 'pending' AND i.created_at < ?)
)",
)
.bind(&threshold)
.fetch_all(pool)
.await?;
let mut reaped: u64 = 0;
for row in rows {
let id: String = row.get("id");
if cancel_redemption(pool, &id).await.is_ok() {
reaped += 1;
}
}
Ok(reaped)
}
/// Background cleanup: drop sessions whose `expires_at` is in the past.
/// Returns the number of rows removed (for logging).
pub async fn reap_expired_sessions(pool: &SqlitePool) -> AppResult<u64> {
+29
View File
@@ -154,6 +154,35 @@ async fn main() -> anyhow::Result<()> {
});
}
// 5-min discount-redemption reaper — frees discount-code slots that
// were reserved at /v1/purchase time for buyers who never paid.
// Two failure cases get cleaned up here: (a) BTCPay fired
// InvoiceExpired/InvoiceInvalid but our webhook handler failed to
// release the slot inline, and (b) BTCPay never delivered the
// expiry webhook at all. 30-min stale threshold covers BTCPay's
// default 15-min invoice expiry plus a buffer for webhook delivery.
{
let pool = state.db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Burn the immediate-fire tick so first real run is 5 min
// after boot — gives webhook handling a chance to settle
// any racing-with-startup invoices.
interval.tick().await;
loop {
interval.tick().await;
match db::repo::reap_stale_pending_redemptions(&pool, 30).await {
Ok(n) if n > 0 => tracing::info!(
"reaped {n} stale discount-code redemption(s); slots returned to pool"
),
Ok(_) => {}
Err(e) => tracing::warn!("redemption reaper: {e}"),
}
}
});
}
// Self-tier live refresher — re-reads the daemon's own license
// row from the local DB every hour and updates `state.self_tier`
// with the live entitlements. Without this, an admin Change Tier