| 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 |
+ |
}
|