Skip to main content

max / makenotwork

Fix PAY-S1: release a pending-refund claim when processing fails check_pending_refund marked the pending refund matched (matched_at = NOW()) before the fallible handle_charge_refunded. On failure it only logged, leaving the row claimed forever: get_stale_refunds filters matched_at IS NULL so the escalation sweep never saw it, and the original charge.refunded event was already marked processed so Stripe never redelivered. Net: refunded buyer kept access + license keys and sales_count stayed inflated, with only a log line. handle_charge_refunded is atomic (one tx) and idempotent, so a failed attempt commits nothing. Add unclaim_pending_refund (matched_at -> NULL) and call it on the error path, re-entering the row into the stale-refund sweep (human escalation) and allowing a later delivery to re-claim and retry. Regression tests: a trigger-forced refund failure releases the claim (matched_at NULL, transaction not refunded); the success path still claims-and-keeps and applies the refund. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 00:27 UTC
Commit: 784e61b53fef777243714776e58d8860c76fec5d
Parent: 3790a3f
3 files changed, +194 insertions, -1 deletion
@@ -72,6 +72,25 @@ pub async fn claim_pending_refund(
72 72 Ok(row)
73 73 }
74 74
75 + /// Release a claimed pending refund back to the queue (`matched_at` → NULL) after
76 + /// its processing failed.
77 + ///
78 + /// `claim_pending_refund` marks the row matched BEFORE the fallible refund work
79 + /// runs. If that work errors, the claim must be released or the row is lost
80 + /// forever: `get_stale_refunds` only sees `matched_at IS NULL` rows, and the
81 + /// original `charge.refunded` event was already marked processed, so Stripe will
82 + /// not redeliver it (PAY-S1). Releasing the claim re-enters the row into the
83 + /// stale-refund sweep (human escalation) and lets a later delivery re-claim and
84 + /// retry — safe because the refund handler is atomic, so a failed attempt
85 + /// committed nothing. Idempotent.
86 + pub async fn unclaim_pending_refund(pool: &PgPool, id: uuid::Uuid) -> Result<()> {
87 + sqlx::query("UPDATE pending_refunds SET matched_at = NULL WHERE id = $1")
88 + .bind(id)
89 + .execute(pool)
90 + .await?;
91 + Ok(())
92 + }
93 +
75 94 /// Row for stale pending refunds that need escalation.
76 95 #[derive(Debug, sqlx::FromRow)]
77 96 pub struct StaleRefund {
@@ -173,8 +173,21 @@ pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &s
173 173 if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data).await {
174 174 tracing::error!(
175 175 error = ?e, pending_refund_id = %pending.id,
176 - "failed to process pending refund after payment completion"
176 + "failed to process pending refund after payment completion — releasing claim"
177 177 );
178 + // The claim was taken before this fallible work. `handle_charge_refunded`
179 + // is atomic, so on error nothing committed; release the claim so the
180 + // stale-refund sweep escalates it and a later delivery can retry. Without
181 + // this the row stays matched forever and the refund is silently lost
182 + // (PAY-S1) — the sweep filters `matched_at IS NULL` and the original
183 + // refund event was already marked processed, so Stripe won't redeliver.
184 + if let Err(e2) = db::pending_refunds::unclaim_pending_refund(&state.db, pending.id).await {
185 + tracing::error!(
186 + error = ?e2, pending_refund_id = %pending.id,
187 + "failed to release pending refund claim after a processing failure — \
188 + refund needs manual intervention"
189 + );
190 + }
178 191 }
179 192 }
180 193
@@ -1895,3 +1895,164 @@ async fn fan_plus_renewal_credit_issued_once_across_duplicate_deliveries() {
1895 1895 "a duplicate delivery of the same renewal must not double-issue the credit"
1896 1896 );
1897 1897 }
1898 +
1899 + // ---------------------------------------------------------------------------
1900 + // PAY-S1: a failed pending-refund process must RELEASE its claim
1901 + //
1902 + // check_pending_refund claims the pending refund (matched_at = NOW()) before the
1903 + // fallible handle_charge_refunded. If that work fails, the claim must be released
1904 + // (matched_at -> NULL) so the stale-refund sweep escalates it and a later
1905 + // delivery can retry. Without the release the row is lost forever: the sweep
1906 + // filters `matched_at IS NULL`, and the original charge.refunded event was
1907 + // already marked processed so Stripe never redelivers. We force the failure with
1908 + // a trigger that raises when the refund flips the transaction to 'refunded'.
1909 + // ---------------------------------------------------------------------------
1910 +
1911 + #[tokio::test]
1912 + async fn pending_refund_releases_claim_when_processing_fails() {
1913 + let mut h = TestHarness::with_stripe().await;
1914 +
1915 + let buyer_id = h.signup("prfbuyer", "prfbuyer@test.com", "password123").await;
1916 + h.client.post_form("/logout", "").await;
1917 + let seller_id = h.signup("prfseller", "prfseller@test.com", "password123").await;
1918 + h.grant_creator(seller_id).await;
1919 + h.client.post_form("/logout", "").await;
1920 + h.login("prfseller", "password123").await;
1921 +
1922 + let resp = h.client.post_form("/api/projects", "slug=prfproj&title=PRF+Project").await;
1923 + let project: Value = resp.json();
1924 + let project_id = project["id"].as_str().unwrap().to_string();
1925 + let resp = h
1926 + .client
1927 + .post_form(
1928 + &format!("/api/projects/{}/items", project_id),
1929 + "title=PRF+Track&price_cents=999&item_type=audio",
1930 + )
1931 + .await;
1932 + let item: Value = resp.json();
1933 + let item_id = item["id"].as_str().unwrap().to_string();
1934 +
1935 + let session_id = "cs_prf_001";
1936 + let pi = "pi_faultrefund_001";
1937 +
1938 + // Pending transaction for this checkout session, carrying the sentinel PI.
1939 + sqlx::query(
1940 + r#"INSERT INTO transactions
1941 + (buyer_id, seller_id, item_id, amount_cents, status,
1942 + stripe_checkout_session_id, stripe_payment_intent_id, item_title, seller_username)
1943 + VALUES ($1, $2, $3::uuid, 999, 'pending', $4, $5, 'PRF Track', 'prfseller')"#,
1944 + )
1945 + .bind(buyer_id).bind(seller_id).bind(&item_id).bind(session_id).bind(pi)
1946 + .execute(&h.db).await.unwrap();
1947 +
1948 + // A full refund for this PI arrived first and was queued.
1949 + sqlx::query("INSERT INTO pending_refunds (payment_intent_id, amount, amount_refunded) VALUES ($1, 999, 999)")
1950 + .bind(pi).execute(&h.db).await.unwrap();
1951 +
1952 + // Force handle_charge_refunded to fail: raise when the refund flips this PI's
1953 + // transaction to 'refunded' (the completion -> 'completed' update is untouched).
1954 + sqlx::query(
1955 + r#"CREATE OR REPLACE FUNCTION test_refund_fault() RETURNS trigger AS $$
1956 + BEGIN
1957 + IF NEW.status = 'refunded' AND COALESCE(NEW.stripe_payment_intent_id,'') LIKE '%faultrefund%' THEN
1958 + RAISE EXCEPTION 'injected refund fault';
1959 + END IF;
1960 + RETURN NEW;
1961 + END; $$ LANGUAGE plpgsql"#,
1962 + ).execute(&h.db).await.unwrap();
1963 + sqlx::query("CREATE TRIGGER test_refund_fault BEFORE UPDATE ON transactions FOR EACH ROW EXECUTE FUNCTION test_refund_fault()")
1964 + .execute(&h.db).await.unwrap();
1965 +
1966 + // Complete the checkout: fulfillment completes the tx, then check_pending_refund
1967 + // claims the refund and tries to process it -> trigger raises -> claim released.
1968 + let mut meta = HashMap::new();
1969 + meta.insert("buyer_id".to_string(), buyer_id.to_string());
1970 + meta.insert("seller_id".to_string(), seller_id.to_string());
1971 + meta.insert("item_id".to_string(), item_id.clone());
1972 + let session = serde_json::json!({
1973 + "id": session_id,
1974 + "object": "checkout_session",
1975 + "mode": "payment",
1976 + "metadata": meta,
1977 + "payment_intent": pi,
1978 + });
1979 + let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
1980 + assert_eq!(resp.status.as_u16(), 200, "checkout webhook should still 200: {}", resp.text);
1981 +
1982 + // The claim must be RELEASED so the stale-refund sweep can pick it up.
1983 + let matched_at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
1984 + "SELECT matched_at FROM pending_refunds WHERE payment_intent_id = $1",
1985 + ).bind(pi).fetch_one(&h.db).await.unwrap();
1986 + assert!(
1987 + matched_at.is_none(),
1988 + "a failed pending-refund process must release its claim (matched_at NULL), else the refund is lost"
1989 + );
1990 +
1991 + // The refund rolled back atomically: the transaction is NOT marked refunded.
1992 + let status: String = sqlx::query_scalar(
1993 + "SELECT status FROM transactions WHERE stripe_payment_intent_id = $1",
1994 + ).bind(pi).fetch_one(&h.db).await.unwrap();
1995 + assert_ne!(status, "refunded", "the failed refund must not have committed");
1996 + }
1997 +
1998 + // Complement: a pending refund that processes SUCCESSFULLY on completion is
1999 + // consumed (matched_at set, transaction refunded) — pins that the happy path
2000 + // still claims-and-keeps, so the release above is failure-only.
2001 + #[tokio::test]
2002 + async fn pending_refund_processed_on_completion_marks_matched() {
2003 + let mut h = TestHarness::with_stripe().await;
2004 +
2005 + let buyer_id = h.signup("prfok_buyer", "prfokb@test.com", "password123").await;
2006 + h.client.post_form("/logout", "").await;
2007 + let seller_id = h.signup("prfok_seller", "prfoks@test.com", "password123").await;
2008 + h.grant_creator(seller_id).await;
2009 + h.client.post_form("/logout", "").await;
2010 + h.login("prfok_seller", "password123").await;
2011 +
2012 + let resp = h.client.post_form("/api/projects", "slug=prfokproj&title=PRFOK").await;
2013 + let project: Value = resp.json();
2014 + let project_id = project["id"].as_str().unwrap().to_string();
2015 + let resp = h
2016 + .client
2017 + .post_form(
2018 + &format!("/api/projects/{}/items", project_id),
2019 + "title=PRFOK+Track&price_cents=999&item_type=audio",
2020 + )
2021 + .await;
2022 + let item: Value = resp.json();
2023 + let item_id = item["id"].as_str().unwrap().to_string();
2024 +
2025 + let session_id = "cs_prfok_001";
2026 + let pi = "pi_prfok_001";
2027 + sqlx::query(
2028 + r#"INSERT INTO transactions
2029 + (buyer_id, seller_id, item_id, amount_cents, status,
2030 + stripe_checkout_session_id, stripe_payment_intent_id, item_title, seller_username)
2031 + VALUES ($1, $2, $3::uuid, 999, 'pending', $4, $5, 'PRFOK Track', 'prfok_seller')"#,
2032 + )
2033 + .bind(buyer_id).bind(seller_id).bind(&item_id).bind(session_id).bind(pi)
2034 + .execute(&h.db).await.unwrap();
2035 + sqlx::query("INSERT INTO pending_refunds (payment_intent_id, amount, amount_refunded) VALUES ($1, 999, 999)")
2036 + .bind(pi).execute(&h.db).await.unwrap();
2037 +
2038 + let mut meta = HashMap::new();
2039 + meta.insert("buyer_id".to_string(), buyer_id.to_string());
2040 + meta.insert("seller_id".to_string(), seller_id.to_string());
2041 + meta.insert("item_id".to_string(), item_id.clone());
2042 + let session = serde_json::json!({
2043 + "id": session_id, "object": "checkout_session", "mode": "payment",
2044 + "metadata": meta, "payment_intent": pi,
2045 + });
2046 + let resp = post_event_json(&mut h, "checkout.session.completed", session).await;
2047 + assert_eq!(resp.status.as_u16(), 200, "checkout webhook failed: {}", resp.text);
2048 +
2049 + let matched_at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
2050 + "SELECT matched_at FROM pending_refunds WHERE payment_intent_id = $1",
2051 + ).bind(pi).fetch_one(&h.db).await.unwrap();
2052 + assert!(matched_at.is_some(), "a successfully processed pending refund stays claimed");
2053 +
2054 + let status: String = sqlx::query_scalar(
2055 + "SELECT status FROM transactions WHERE stripe_payment_intent_id = $1",
2056 + ).bind(pi).fetch_one(&h.db).await.unwrap();
2057 + assert_eq!(status, "refunded", "the out-of-order refund must apply on completion");
2058 + }