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:
@@ -2817,6 +2817,55 @@ pub async fn delete_all_sessions(pool: &SqlitePool) -> AppResult<()> {
|
|||||||
Ok(())
|
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.
|
/// Background cleanup: drop sessions whose `expires_at` is in the past.
|
||||||
/// Returns the number of rows removed (for logging).
|
/// Returns the number of rows removed (for logging).
|
||||||
pub async fn reap_expired_sessions(pool: &SqlitePool) -> AppResult<u64> {
|
pub async fn reap_expired_sessions(pool: &SqlitePool) -> AppResult<u64> {
|
||||||
|
|||||||
@@ -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
|
// Self-tier live refresher — re-reads the daemon's own license
|
||||||
// row from the local DB every hour and updates `state.self_tier`
|
// row from the local DB every hour and updates `state.self_tier`
|
||||||
// with the live entitlements. Without this, an admin Change Tier
|
// with the live entitlements. Without this, an admin Change Tier
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:40 — **Discount-code slot reaper plugs the abandoned-cart leak.** When a buyer clicked Pay with Bitcoin with a discount code applied, the daemon reserved a slot on that code (incrementing `used_count`) BEFORE creating the BTCPay invoice. This is the right pessimistic-lock behavior — prevents two buyers from racing for the last slot of a limited code — but it meant abandoned checkouts only freed the slot when BTCPay later fired `InvoiceExpired`. If that webhook never landed (network blip, daemon offline at the firing moment, misconfigured webhook URL), the slot leaked forever. New 5-minute background reaper closes both holes: scans `discount_redemptions` where status=\'pending\' and the linked invoice is either in a terminal failure state (\'expired\' / \'invalid\') OR has been sitting in \'pending\' for more than 30 minutes, and cancels each one — flipping the redemption to \'cancelled\' and decrementing the code\'s `used_count` so the slot is available again. 30-min threshold covers BTCPay\'s default 15-min invoice expiry plus webhook-delivery buffer. Lives alongside the existing hourly session reaper in `main.rs`. Internal-only; no API or schema change. Operator-visible only in the sense that limited-discount slots no longer drift over time.',
|
||||||
|
'',
|
||||||
'0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
|
'0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
|
||||||
'',
|
'',
|
||||||
'0.2.0:38 — **Admin UI: Create-product Cancel button + modal-overflow fix across all dialogs.** Two operator-reported bugs.',
|
'0.2.0:38 — **Admin UI: Create-product Cancel button + modal-overflow fix across all dialogs.** Two operator-reported bugs.',
|
||||||
@@ -507,7 +509,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:39',
|
version: '0.2.0:40',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user