diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 01e8143..1f0fddb 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -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 { + 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 { diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index 9affb80..920bacc 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -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 diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 17a8b7b..03f053b 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,8 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. 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: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') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:39', + version: '0.2.0:40', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under