Skip to main content

max / makenotwork

server: line-aware cart refunds + orphaned-paid-session escalation (ultra-fuzz Run 2 Payments SERIOUS) SERIOUS (cart refund): cart checkout puts every line of an order under one PaymentIntent, but the self-service refund issued a PI-wide Stripe refund and the charge.refunded webhook marked ALL of that PI's transactions refunded — so refunding one item silently reversed the whole order (access + license keys for every line). Refunds are now line-scoped: create_refund_for_transaction refunds only that line's amount and tags the Stripe refund with mnw_transaction_id; a new refund.created/updated webhook marks and revokes exactly that transaction (refund_transaction_by_id). charge.refunded stays the out-of-band-full-refund net and no longer false-queues a pending refund when the rows are already refunded (transaction_exists_for_payment_intent guard). revoke_refunded_transaction is shared by both paths so they can't drift. SERIOUS (orphaned paid session): a checkout.session.completed that completes zero pending rows was logged as a benign "duplicate" — but it also covers the case where the pending rows were never created (e.g. create_cart_pending_transactions failed after the Stripe session opened), i.e. the buyer paid and got nothing. escalate_if_orphaned_session now distinguishes a real duplicate (rows exist) from an orphaned session (none exist) via transaction_exists_for_checkout_session and opens a WAM ticket for the latter. Applied to the cart, purchase, and guest handlers via one shared helper so the three can't drift. The cart checkout path also releases the promo reservation and logs loudly if pending-row creation fails after the session opens. MINOR: the tip checkout handler now logs its event by id for audit parity with the other handlers (record_tip_splits mints split revenue). Adds a workflow test proving a single cart line refund leaves the order's other lines completed. The webhook_v2 retry-queue parity MINOR is deferred (it needs retry-worker routing for v2 thin events; Stripe's 3-day redelivery already nets failures).
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 16:35 UTC
Signed with PGP, not checked
Commit: f66d659fbfd95f81a534b2882fb9d21c454e356a
Parent: 5a9a452
14 files changed, +535 insertions, -44 deletions
@@ -857,6 +857,68 @@
857 857 Ok(rows.into_iter().map(|r| (r.id, r.item_id)).collect())
858 858 }
859 859
860 + /// Mark a SINGLE transaction refunded by id, returning `(id, item_id)` if it
861 + /// transitioned from `completed`. Returns `None` if it was already refunded or
862 + /// otherwise not refundable (idempotent for webhook re-delivery).
863 + ///
864 + /// Used by the line-scoped `refund.created` handler: cart lines share a
865 + /// payment_intent, so refunding one line must touch only its own row — never the
866 + /// PI-wide [`refund_transaction_by_payment_intent`] (Run #2 Payments SERIOUS).
867 + #[tracing::instrument(skip_all)]
868 + pub async fn refund_transaction_by_id<'e>(
869 + executor: impl sqlx::PgExecutor<'e>,
870 + id: TransactionId,
871 + ) -> Result<Option<(super::TransactionId, Option<ItemId>)>> {
872 + let row = sqlx::query!(
873 + r#"
874 + UPDATE transactions
875 + SET status = 'refunded'
876 + WHERE id = $1 AND status = 'completed'
877 + RETURNING id AS "id: super::TransactionId", item_id AS "item_id: ItemId"
878 + "#,
879 + id as TransactionId,
880 + )
881 + .fetch_optional(executor)
882 + .await?;
883 +
884 + Ok(row.map(|r| (r.id, r.item_id)))
885 + }
886 +
887 + /// True if any transaction (any status) references this payment_intent. Lets the
888 + /// `charge.refunded` handler tell "already refunded" (line-scoped refunds marked
889 + /// the rows) apart from "genuinely unmatched" before queuing a pending refund.
890 + pub async fn transaction_exists_for_payment_intent<'e>(
891 + executor: impl sqlx::PgExecutor<'e>,
892 + payment_intent_id: &str,
893 + ) -> Result<bool> {
894 + let exists = sqlx::query_scalar!(
895 + r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_payment_intent_id = $1) AS "exists!""#,
896 + payment_intent_id,
897 + )
898 + .fetch_one(executor)
899 + .await?;
900 +
901 + Ok(exists)
902 + }
903 +
904 + /// True if any transaction (any status) references this checkout session. Lets
905 + /// the cart-completion webhook tell a benign duplicate delivery (rows already
906 + /// completed) apart from an ORPHANED paid session (rows never created — buyer
907 + /// charged, got nothing) so the latter is escalated (Run #2 Payments SERIOUS).
908 + pub async fn transaction_exists_for_checkout_session<'e>(
909 + executor: impl sqlx::PgExecutor<'e>,
910 + checkout_session_id: &str,
911 + ) -> Result<bool> {
912 + let exists = sqlx::query_scalar!(
913 + r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_checkout_session_id = $1) AS "exists!""#,
914 + checkout_session_id,
915 + )
916 + .fetch_one(executor)
917 + .await?;
918 +
919 + Ok(exists)
920 + }
921 +
860 922 /// Revoke all child transactions linked to a parent (bundle) transaction.
861 923 ///
862 924 /// Returns the item IDs of revoked children so callers can decrement sales counts.
@@ -292,22 +292,36 @@
292 292 Ok(session.url)
293 293 }
294 294
295 - /// Issue a full refund for a payment on a connected account.
296 - #[tracing::instrument(skip_all, name = "payments::create_refund")]
297 - pub async fn create_refund(
295 + /// Issue a line-scoped refund for one transaction on a connected account.
296 + ///
297 + /// `amount_cents` is refunded against the shared PaymentIntent and the Stripe
298 + /// refund is tagged with `mnw_transaction_id` so the `refund.created` webhook
299 + /// marks and revokes exactly that transaction. Cart checkouts put every line
300 + /// of an order under ONE PaymentIntent, so a PI-wide refund would silently
301 + /// reverse the whole order (Run #2 Payments SERIOUS).
302 + #[tracing::instrument(skip_all, name = "payments::create_refund_for_transaction")]
303 + pub async fn create_refund_for_transaction(
298 304 &self,
299 305 payment_intent_id: &str,
300 306 connected_account_id: &str,
307 + amount_cents: i64,
308 + transaction_id: crate::db::TransactionId,
301 309 ) -> Result<()> {
302 310 let acct = Self::parse_account_id(connected_account_id)?;
311 + let metadata = std::collections::HashMap::from([(
312 + "mnw_transaction_id".to_string(),
313 + transaction_id.to_string(),
314 + )]);
303 315 CreateRefund::new()
304 316 .payment_intent(payment_intent_id.to_string())
317 + .amount(amount_cents)
318 + .metadata(metadata)
305 319 .customize()
306 320 .account_id(acct)
307 321 .send(&self.client)
308 322 .await
309 323 .map_err(|e| {
310 - tracing::error!(payment_intent_id = %payment_intent_id, error = ?e, "failed to create Stripe refund");
324 + tracing::error!(payment_intent_id = %payment_intent_id, transaction_id = %transaction_id, error = ?e, "failed to create Stripe line refund");
311 325 AppError::Internal(anyhow::anyhow!("Failed to create refund"))
312 326 })?;
313 327 Ok(())
@@ -106,8 +106,16 @@
106 106 /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to.
107 107 async fn create_billing_portal_session(&self, stripe_customer_id: &str, return_url: &str) -> crate::error::Result<String>;
108 108
109 - // Refunds
110 - async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
109 + // Refunds — line-scoped: refunds `amount_cents` of the shared PaymentIntent
110 + // and tags the refund with the transaction id so the refund.created webhook
111 + // marks/revokes exactly that line (cart orders share one PaymentIntent).
112 + async fn create_refund_for_transaction(
113 + &self,
114 + payment_intent_id: &str,
115 + connected_account_id: &str,
116 + amount_cents: i64,
117 + transaction_id: crate::db::TransactionId,
118 + ) -> crate::error::Result<()>;
111 119
112 120 // Webhooks
113 121 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
@@ -229,8 +237,21 @@
229 237 StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await
230 238 }
231 239
232 - async fn create_refund(&self, payment_intent_id: &str, connected_account_id: &str) -> crate::error::Result<()> {
233 - StripeClient::create_refund(self, payment_intent_id, connected_account_id).await
240 + async fn create_refund_for_transaction(
241 + &self,
242 + payment_intent_id: &str,
243 + connected_account_id: &str,
244 + amount_cents: i64,
245 + transaction_id: crate::db::TransactionId,
246 + ) -> crate::error::Result<()> {
247 + StripeClient::create_refund_for_transaction(
248 + self,
249 + payment_intent_id,
250 + connected_account_id,
251 + amount_cents,
252 + transaction_id,
253 + )
254 + .await
234 255 }
235 256
236 257 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
@@ -316,6 +316,35 @@
316 316 }
317 317 }
318 318
319 + /// Narrow view of a Refund object (`refund.created` / `refund.updated` events).
320 + ///
321 + /// The line-scoped self-service refund tags the Stripe refund with
322 + /// `metadata.mnw_transaction_id`; the webhook reads it back so a cart line refund
323 + /// marks/revokes exactly its own transaction rather than the whole order.
324 + #[derive(Debug, serde::Deserialize)]
325 + pub struct RefundView {
326 + #[serde(default)]
327 + pub amount: i64,
328 + pub status: Option<String>,
329 + #[serde(default, deserialize_with = "deserialize_expandable_id")]
330 + pub payment_intent: Option<String>,
331 + #[serde(default)]
332 + pub metadata: Option<std::collections::HashMap<String, String>>,
333 + }
334 +
335 + impl RefundView {
336 + /// The MNW transaction id this refund was tagged with at creation, if any.
337 + /// Absent for out-of-band refunds (e.g. issued from the Stripe dashboard).
338 + pub fn mnw_transaction_id(&self) -> Option<&str> {
339 + self.metadata.as_ref()?.get("mnw_transaction_id").map(String::as_str)
340 + }
341 +
342 + /// Stripe marks a completed refund `succeeded`; only then is the money back.
343 + pub fn is_succeeded(&self) -> bool {
344 + self.status.as_deref() == Some("succeeded")
345 + }
346 + }
347 +
319 348 // ---------------------------------------------------------------------------
320 349 // v2 thin event types
321 350 // ---------------------------------------------------------------------------
@@ -71,6 +71,18 @@
71 71 /// `trial_days` passed to each creator-tier checkout, in call order. Lets
72 72 /// the comp-code test assert the trial was actually threaded to Stripe.
73 73 creator_tier_trial_days: Mutex<Vec<Option<i32>>>,
74 + /// Line-scoped refunds requested, in call order. Lets tests assert a cart
75 + /// line refund hits Stripe for only that line's amount + transaction id.
76 + refunds: Mutex<Vec<MockRefund>>,
77 + }
78 +
79 + /// A line-scoped refund captured by the mock.
80 + #[derive(Debug, Clone)]
81 + #[allow(dead_code)]
82 + pub struct MockRefund {
83 + pub payment_intent_id: String,
84 + pub amount_cents: i64,
85 + pub transaction_id: makenotwork::db::TransactionId,
74 86 }
75 87
76 88 #[allow(dead_code)]
@@ -80,9 +92,15 @@
80 92 checkouts: Mutex::new(Vec::new()),
81 93 next_checkout_id: Mutex::new(1),
82 94 creator_tier_trial_days: Mutex::new(Vec::new()),
95 + refunds: Mutex::new(Vec::new()),
83 96 }
84 97 }
85 98
99 + /// All line-scoped refunds requested so far.
100 + pub fn refunds(&self) -> Vec<MockRefund> {
101 + self.refunds.lock().unwrap().clone()
102 + }
103 +
86 104 /// Return all checkouts created so far.
87 105 pub fn checkouts(&self) -> Vec<MockCheckout> {
88 106 self.checkouts.lock().unwrap().clone()
@@ -204,7 +222,18 @@
204 222 Ok(format!("https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url)))
205 223 }
206 224
207 - async fn create_refund(&self, _payment_intent_id: &str, _connected_account_id: &str) -> Result<()> {
225 + async fn create_refund_for_transaction(
226 + &self,
227 + payment_intent_id: &str,
228 + _connected_account_id: &str,
229 + amount_cents: i64,
230 + transaction_id: makenotwork::db::TransactionId,
231 + ) -> Result<()> {
232 + self.refunds.lock().unwrap().push(MockRefund {
233 + payment_intent_id: payment_intent_id.to_string(),
234 + amount_cents,
235 + transaction_id,
236 + });
208 237 Ok(())
209 238 }
210 239
@@ -1566,6 +1566,114 @@
1566 1566 assert_eq!(amount_refunded, 1500);
1567 1567 }
1568 1568
1569 + #[tokio::test]
1570 + async fn webhook_refund_created_line_scoped_does_not_reverse_cart() {
1571 + // Run #2 Payments SERIOUS: a cart puts every line of an order under ONE
1572 + // payment_intent. A self-service refund of one line tags the Stripe refund
1573 + // with mnw_transaction_id; the refund.created webhook must mark/revoke ONLY
1574 + // that transaction and leave the order's other lines untouched.
1575 + let mut h = TestHarness::with_stripe().await;
1576 +
1577 + let buyer_id = h.signup("clbuyer", "clb@test.com", "password123").await;
1578 + h.client.post_form("/logout", "").await;
1579 + let seller_id = h.signup("clseller", "cls@test.com", "password123").await;
1580 + h.grant_creator(seller_id).await;
1581 + h.client.post_form("/logout", "").await;
1582 + h.login("clseller", "password123").await;
1583 +
1584 + let resp = h
1585 + .client
1586 + .post_form("/api/projects", "slug=cartproj&title=Cart+Project")
1587 + .await;
1588 + let project: Value = resp.json();
1589 + let project_id = project["id"].as_str().unwrap().to_string();
1590 +
1591 + // Two items, both sold to the same buyer in one cart (shared payment_intent).
1592 + let mut item_ids = Vec::new();
1593 + for (n, title) in [("Line+One", "a"), ("Line+Two", "b")] {
1594 + let _ = title;
1595 + let resp = h
1596 + .client
1597 + .post_form(
1598 + &format!("/api/projects/{}/items", project_id),
1599 + &format!("title={n}&price_cents=500&item_type=audio"),
1600 + )
1601 + .await;
1602 + let item: Value = resp.json();
1603 + item_ids.push(item["id"].as_str().unwrap().to_string());
1604 + }
1605 +
1606 + let pi_id = "pi_cart_line_refund";
1607 + let mut tx_ids = Vec::new();
1608 + for item_id in &item_ids {
1609 + sqlx::query("UPDATE items SET sales_count = 1 WHERE id = $1::uuid")
1610 + .bind(item_id)
1611 + .execute(&h.db)
1612 + .await
1613 + .unwrap();
1614 + let tx_id: uuid::Uuid = sqlx::query_scalar(
1615 + r#"INSERT INTO transactions
1616 + (buyer_id, seller_id, item_id, amount_cents, status,
1617 + stripe_payment_intent_id, stripe_checkout_session_id,
1618 + item_title, seller_username, completed_at)
1619 + VALUES ($1, $2, $3::uuid, 500, 'completed', $4, 'cs_cart', 'Line', 'clseller', NOW())
1620 + RETURNING id"#,
1621 + )
1622 + .bind(buyer_id)
1623 + .bind(seller_id)
1624 + .bind(item_id)
1625 + .bind(pi_id)
1626 + .fetch_one(&h.db)
1627 + .await
1628 + .unwrap();
1629 + tx_ids.push(tx_id);
1630 + }
1631 +
1632 + // Refund ONLY the first line via refund.created carrying its transaction id.
1633 + let refund = serde_json::json!({
1634 + "id": "re_cart_line_1",
1635 + "object": "refund",
1636 + "amount": 500,
1637 + "status": "succeeded",
1638 + "payment_intent": pi_id,
1639 + "metadata": { "mnw_transaction_id": tx_ids[0].to_string() },
1640 + });
1641 + let resp = post_event_json(&mut h, "refund.created", refund).await;
1642 + assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
1643 +
1644 + // Line one is refunded; line two is untouched (the bug would refund both).
1645 + let status_one: String =
1646 + sqlx::query_scalar("SELECT status FROM transactions WHERE id = $1")
1647 + .bind(tx_ids[0])
1648 + .fetch_one(&h.db)
1649 + .await
1650 + .unwrap();
1651 + let status_two: String =
1652 + sqlx::query_scalar("SELECT status FROM transactions WHERE id = $1")
1653 + .bind(tx_ids[1])
1654 + .fetch_one(&h.db)
1655 + .await
1656 + .unwrap();
1657 + assert_eq!(status_one, "refunded", "refunded line must be marked refunded");
1658 + assert_eq!(status_two, "completed", "sibling cart line must NOT be reversed");
1659 +
1660 + // Sales count: only the refunded line's item decremented.
1661 + let sales_one: i32 =
1662 + sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
1663 + .bind(&item_ids[0])
1664 + .fetch_one(&h.db)
1665 + .await
1666 + .unwrap();
1667 + let sales_two: i32 =
1668 + sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
1669 + .bind(&item_ids[1])
1670 + .fetch_one(&h.db)
1671 + .await
1672 + .unwrap();
1673 + assert_eq!(sales_one, 0, "refunded line's sale decremented");
1674 + assert_eq!(sales_two, 1, "sibling line's sale stands");
1675 + }
1676 +
1569 1677 // ---------------------------------------------------------------------------
1570 1678 // Subscription / invoice edge cases (test-fuzz Phase 2.1)
1571 1679 // ---------------------------------------------------------------------------