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
Commit: f66d659fbfd95f81a534b2882fb9d21c454e356a
Parent: 5a9a452
14 files changed, +535 insertions, -44 deletions
@@ -0,0 +1,22 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_checkout_session_id = $1) AS \"exists!\"",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "exists!",
9 + "type_info": "Bool"
10 + }
11 + ],
12 + "parameters": {
13 + "Left": [
14 + "Text"
15 + ]
16 + },
17 + "nullable": [
18 + null
19 + ]
20 + },
21 + "hash": "4e989ded9c96a21d19a600f0ab66344166ec3f52462605e9116bce92a9c4ec75"
22 + }
@@ -0,0 +1,28 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n UPDATE transactions\n SET status = 'refunded'\n WHERE id = $1 AND status = 'completed'\n RETURNING id AS \"id: super::TransactionId\", item_id AS \"item_id: ItemId\"\n ",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "id: super::TransactionId",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "item_id: ItemId",
14 + "type_info": "Uuid"
15 + }
16 + ],
17 + "parameters": {
18 + "Left": [
19 + "Uuid"
20 + ]
21 + },
22 + "nullable": [
23 + false,
24 + true
25 + ]
26 + },
27 + "hash": "63c9327081c816fd579ac89f2c796f6c9fc75442cca0e6ed36ad88b3e306c57f"
28 + }
@@ -0,0 +1,22 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_payment_intent_id = $1) AS \"exists!\"",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "exists!",
9 + "type_info": "Bool"
10 + }
11 + ],
12 + "parameters": {
13 + "Left": [
14 + "Text"
15 + ]
16 + },
17 + "nullable": [
18 + null
19 + ]
20 + },
21 + "hash": "fb319a64afdb053c7054ec9a1eff88571d661f67b9c4958c1419dd862fae8f3a"
22 + }
@@ -857,6 +857,68 @@ pub async fn refund_transaction_by_payment_intent<'e>(
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 @@ impl StripeClient {
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 @@ pub trait PaymentProvider: Send + Sync {
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 @@ impl PaymentProvider for StripeClient {
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 @@ impl ChargeRefundData {
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 // ---------------------------------------------------------------------------
@@ -19,11 +19,13 @@ pub struct RefundRequest {
19 19 pub transaction_id: TransactionId,
20 20 }
21 21
22 - /// Issue a full refund for a transaction on this item.
22 + /// Issue a line-scoped refund for one transaction on this item.
23 23 ///
24 - /// The refund is sent to Stripe; the existing `charge.refunded` webhook
25 - /// handler marks the transaction as refunded, revokes license keys, and
26 - /// decrements the sales count.
24 + /// The refund is sent to Stripe for this transaction's amount only, tagged with
25 + /// the transaction id; the `refund.created` webhook marks THAT transaction
26 + /// refunded, revokes its license keys, and decrements its sales count. Cart
27 + /// orders put every line under one PaymentIntent, so refunding the whole PI
28 + /// would silently reverse the entire order (Run #2 Payments SERIOUS).
27 29 #[tracing::instrument(skip_all, name = "items::refund_transaction")]
28 30 pub(in crate::routes::api) async fn refund_transaction(
29 31 State(state): State<AppState>,
@@ -59,10 +61,16 @@ pub(in crate::routes::api) async fn refund_transaction(
59 61 let stripe_account_id = seller.stripe_account_id.as_deref()
60 62 .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?;
61 63
62 - // Issue the refund via Stripe — the webhook handler does the rest
64 + // Issue the line-scoped refund via Stripe — the refund.created webhook marks
65 + // and revokes exactly this transaction (cart orders share a PaymentIntent).
63 66 let stripe = state.stripe.as_ref()
64 67 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
65 - stripe.create_refund(payment_intent_id, stripe_account_id).await?;
68 + stripe.create_refund_for_transaction(
69 + payment_intent_id,
70 + stripe_account_id,
71 + tx.amount_cents.as_i64(),
72 + tx.id,
73 + ).await?;
66 74
67 75 Ok(Json(serde_json::json!({ "ok": true })))
68 76 }
@@ -446,10 +446,24 @@ async fn checkout_seller_cart(
446 446 }
447 447 };
448 448
449 - create_cart_pending_transactions(
449 + if let Err(e) = create_cart_pending_transactions(
450 450 state, user.id, seller_id, &result.id, &still_paid, share_contact, promo_code_id,
451 451 )
452 - .await?;
452 + .await
453 + {
454 + // The Stripe session is already live and cannot be un-created here. If the
455 + // buyer pays it, the cart-completion webhook finds no pending rows and
456 + // escalates it as an orphaned paid session (Run #2 Payments SERIOUS).
457 + // Release the promo reservation so it isn't stuck held by a dead session.
458 + if let Some(pc_id) = promo_code_id {
459 + release_promo_quietly(state, pc_id, user.id).await;
460 + }
461 + tracing::error!(
462 + session_id = %result.id, error = ?e,
463 + "failed to create cart pending transactions after opening Stripe session; session is orphaned if paid"
464 + );
465 + return Err(e).context("create cart pending transactions");
466 + }
453 467
454 468 // Cart items are removed by the webhook handler on successful payment, so a
455 469 // canceled Stripe checkout leaves the cart intact.
@@ -287,6 +287,85 @@ pub(super) async fn handle_invoice_payment_failed(
287 287 Ok(())
288 288 }
289 289
290 + /// Revoke a single refunded transaction: decrement its item's sales count,
291 + /// revoke its license keys, and revoke + decrement any bundle-child transactions.
292 + /// Returns `(keys_revoked, children_revoked)` for logging. Caller must have
293 + /// already transitioned the row to `refunded` (so this runs exactly once per
294 + /// transaction). Shared by the PI-wide `charge.refunded` path and the
295 + /// line-scoped `refund.created` path.
296 + async fn revoke_refunded_transaction(
297 + db_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
298 + tx_id: db::TransactionId,
299 + item_id: Option<db::ItemId>,
300 + ) -> Result<(u64, usize)> {
301 + // Project-level transactions store item_id IS NULL — skip the item-scoped
302 + // updates for those; the project-members split rows aren't sales-counted.
303 + if let Some(item_id) = item_id {
304 + db::items::decrement_sales_count(&mut **db_tx, item_id).await.context("decrement sales count")?;
305 + }
306 +
307 + let keys = db::license_keys::revoke_keys_by_transaction(db_tx, tx_id).await.context("revoke license keys")?;
308 +
309 + // Revoke child transactions granted via bundle purchase
310 + let revoked_children = db::transactions::revoke_child_transactions(&mut **db_tx, tx_id)
311 + .await.context("revoke bundle child transactions")?;
312 + for child_item_id in &revoked_children {
313 + db::items::decrement_sales_count(&mut **db_tx, *child_item_id)
314 + .await
315 + .context("decrement child item sales count")?;
316 + }
317 + Ok((keys, revoked_children.len()))
318 + }
319 +
320 + /// Handle a `refund.created` / `refund.updated` webhook for a line-scoped refund.
321 + ///
322 + /// The self-service refund tags the Stripe refund with `mnw_transaction_id`; we
323 + /// mark and revoke exactly that transaction. Cart lines share a PaymentIntent, so
324 + /// this is what keeps a single-line refund from touching the order's other lines
325 + /// (Run #2 Payments SERIOUS). Refunds without our metadata (e.g. issued from the
326 + /// Stripe dashboard) are left to the `charge.refunded` path. Idempotent: the
327 + /// `status = 'completed'` transition guard means re-delivery is a no-op, and a
328 + /// later `charge.refunded` for the same refund finds nothing left to mark.
329 + pub(super) async fn handle_refund_created(
330 + state: &AppState,
331 + refund: &crate::payments::RefundView,
332 + ) -> Result<()> {
333 + if !refund.is_succeeded() {
334 + return Ok(());
335 + }
336 + let Some(tx_id_str) = refund.mnw_transaction_id() else {
337 + return Ok(()); // out-of-band refund; charge.refunded handles full ones
338 + };
339 + let Ok(tx_id) = tx_id_str.parse::<db::TransactionId>() else {
340 + tracing::warn!(mnw_transaction_id = %tx_id_str, "refund metadata transaction id unparseable; ignoring");
341 + return Ok(());
342 + };
343 +
344 + let mut db_tx = state.db.begin().await.context("begin line refund")?;
345 + let refunded = db::transactions::refund_transaction_by_id(&mut *db_tx, tx_id)
346 + .await
347 + .context("refund transaction by id")?;
348 + match refunded {
349 + Some((tx_id, item_id)) => {
350 + let (keys, children) = revoke_refunded_transaction(&mut db_tx, tx_id, item_id).await?;
351 + db_tx.commit().await.context("commit line refund")?;
352 + tracing::info!(
353 + transaction_id = %tx_id,
354 + keys_revoked = keys,
355 + bundle_children_revoked = children,
356 + "line refund processed"
357 + );
358 + }
359 + None => {
360 + tracing::info!(
361 + transaction_id = %tx_id,
362 + "line refund: transaction not in a completed state (already refunded); no-op"
363 + );
364 + }
365 + }
366 + Ok(())
367 + }
368 +
290 369 /// Handle charge.refunded webhook; revoke license keys on full refund,
291 370 /// log partial refunds without revoking access.
292 371 /// Process a full refund: revoke transactions/keys/access, or (for the direct
@@ -330,24 +409,9 @@ pub(super) async fn handle_charge_refunded(
330 409 let mut total_children_revoked = 0usize;
331 410
332 411 for (tx_id, item_id) in &refunded {
333 - // Project-level transactions store item_id IS NULL — skip the item-scoped
334 - // updates for those; the project-members split rows aren't sales-counted.
335 - if let Some(item_id) = item_id {
336 - db::items::decrement_sales_count(&mut *db_tx, *item_id).await.context("decrement sales count")?;
337 - }
338 -
339 - let revoked = db::license_keys::revoke_keys_by_transaction(&mut db_tx, *tx_id).await.context("revoke license keys")?;
340 - total_keys_revoked += revoked;
341 -
342 - // Revoke child transactions granted via bundle purchase
343 - let revoked_children = db::transactions::revoke_child_transactions(&mut *db_tx, *tx_id)
344 - .await.context("revoke bundle child transactions")?;
345 - for child_item_id in &revoked_children {
346 - db::items::decrement_sales_count(&mut *db_tx, *child_item_id)
347 - .await
348 - .context("decrement child item sales count")?;
349 - }
350 - total_children_revoked += revoked_children.len();
412 + let (keys, children) = revoke_refunded_transaction(&mut db_tx, *tx_id, *item_id).await?;
413 + total_keys_revoked += keys;
414 + total_children_revoked += children;
351 415 }
352 416
353 417 // Commit the refund atomically
@@ -373,13 +437,24 @@ pub(super) async fn handle_charge_refunded(
373 437 .context("refund tip")?;
374 438 if tip_refunded {
375 439 tracing::info!(payment_intent_id = %payment_intent_id, "tip refund processed");
440 + } else if db::transactions::transaction_exists_for_payment_intent(&state.db, payment_intent_id)
441 + .await
442 + .context("check transaction existence for refund")?
443 + {
444 + // Transactions exist for this PI but none were 'completed' — they were
445 + // already refunded (line-scoped refund.created marked them, or a prior
446 + // delivery did). Idempotent no-op; do NOT queue a pending refund.
447 + tracing::info!(
448 + payment_intent_id = %payment_intent_id,
449 + "charge.refunded: transactions already refunded; no-op"
450 + );
376 451 } else if requeue_if_unmatched {
377 - // No matching transaction or tip — the payment webhook likely hasn't
378 - // arrived yet. Queue the refund for later matching rather than
452 + // No transaction at all (and no tip) — the payment webhook likely
453 + // hasn't arrived yet. Queue the refund for later matching rather than
379 454 // silently dropping it.
380 455 tracing::warn!(
381 456 payment_intent_id = %payment_intent_id,
382 - "no completed transaction or tip found — queuing as pending refund"
457 + "no transaction or tip found — queuing as pending refund"
383 458 );
384 459 db::pending_refunds::insert_pending_refund(
385 460 &state.db,
@@ -14,6 +14,45 @@ use super::checkout_helpers::{
14 14 send_tip_email, subscribe_buyer_to_mailing_list,
15 15 };
16 16
17 + /// A `checkout.session.completed` produced no transaction to complete. Tell a
18 + /// benign duplicate webhook (rows already completed) apart from an ORPHANED paid
19 + /// session (rows never created — buyer charged, got nothing) and escalate the
20 + /// latter to WAM instead of silently logging "duplicate" (Run #2 Payments
21 + /// SERIOUS). Shared by the purchase, cart, and guest completion handlers so the
22 + /// three can't drift.
23 + async fn escalate_if_orphaned_session(
24 + state: &AppState,
25 + session_id: &str,
26 + payment_intent_id: &str,
27 + label: &str,
28 + ) -> Result<()> {
29 + let exists = db::transactions::transaction_exists_for_checkout_session(&state.db, session_id)
30 + .await
31 + .context("check session transaction existence")?;
32 + if exists {
33 + tracing::info!(session_id = %session_id, "{label} already completed, ignoring duplicate webhook");
34 + } else {
35 + tracing::error!(
36 + session_id = %session_id, payment_intent_id = %payment_intent_id,
37 + "orphaned paid session ({label}): payment completed but no transaction exists — manual reconciliation required"
38 + );
39 + if let Some(wam) = state.wam.clone() {
40 + let sid = session_id.to_string();
41 + let pi = payment_intent_id.to_string();
42 + let label = label.to_string();
43 + tokio::spawn(async move {
44 + let body = format!(
45 + "Checkout session {sid} (payment_intent {pi}, {label}) completed at Stripe but has \
46 + NO transaction — the pending row(s) were never created. The buyer was charged and \
47 + received nothing. Reconcile manually: refund the payment or recreate the order."
48 + );
49 + wam.create_ticket("Orphaned paid session", Some(&body), "high", "stripe-orphaned-session", Some(&sid)).await;
50 + });
51 + }
52 + }
53 + Ok(())
54 + }
55 +
17 56 /// Handle checkout.session.completed for one-time purchases
18 57 #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")]
19 58 pub(super) async fn handle_purchase_checkout_completed(
@@ -128,7 +167,7 @@ pub(super) async fn handle_purchase_checkout_completed(
128 167 check_pending_refund(state, &payment_intent_id).await;
129 168 }
130 169 Ok(None) => {
131 - tracing::info!(session_id = %session_id, "transaction already completed, ignoring duplicate webhook");
170 + escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "transaction").await?;
132 171 }
133 172 Err(e) => {
134 173 tracing::error!(session_id = %session_id, error = ?e, "failed to complete transaction");
@@ -165,7 +204,7 @@ pub(super) async fn handle_cart_checkout_completed(
165 204 .context("complete cart transactions")?;
166 205
167 206 if completed_txs.is_empty() {
168 - tracing::info!(session_id = %session_id, "cart transactions already completed, ignoring duplicate webhook");
207 + escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "cart transactions").await?;
169 208 return Ok(());
170 209 }
171 210
@@ -554,7 +593,7 @@ pub(super) async fn handle_creator_tier_checkout_completed(
554 593 pub(super) async fn handle_tip_checkout_completed(
555 594 state: &AppState,
556 595 session: &crate::payments::CheckoutSessionView,
557 - _event_id: &str,
596 + event_id: &str,
558 597 ) -> Result<()> {
559 598 let session_id = session.id.clone();
560 599 tracing::info!(session_id = %session_id, "processing completed tip checkout");
@@ -575,6 +614,16 @@ pub(super) async fn handle_tip_checkout_completed(
575 614 amount_cents = %tip.amount_cents, "tip completed"
576 615 );
577 616
617 + // Log the event by id for audit parity with the other checkout
618 + // handlers (record_tip_splits mints split revenue, so an event-id
619 + // ledger entry matters for reconciliation). MINOR, Run #2 Payments.
620 + if let Err(e) = db::subscriptions::log_subscription_event(
621 + &state.db, None, event_id, "checkout.session.completed.tip",
622 + &serde_json::json!({"session_id": session_id, "tip_id": tip.id}),
623 + ).await {
624 + tracing::warn!(event_id = %event_id, error = ?e, "failed to log tip event");
625 + }
626 +
578 627 // Record revenue splits if the tip's project has members
579 628 if let Some(project_id) = tip.project_id {
580 629 record_tip_splits(state, tip.id, project_id, tip.amount_cents).await;
@@ -704,7 +753,7 @@ pub(super) async fn handle_guest_checkout_completed(
704 753 }
705 754 None => {
706 755 db_tx.commit().await.ok();
707 - tracing::info!(session_id = %session_id, "guest transaction already completed, ignoring duplicate webhook");
756 + escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "guest transaction").await?;
708 757 }
709 758 }
710 759
@@ -17,7 +17,7 @@ use crate::{
17 17 error::{AppError, Result, ResultExt},
18 18 payments::{
19 19 self, AccountUpdate, AccountView, ChargeRefundData, ChargeView,
20 - CheckoutSessionView, InvoiceView, SubscriptionView, UntypedEvent,
20 + CheckoutSessionView, InvoiceView, RefundView, SubscriptionView, UntypedEvent,
21 21 },
22 22 AppState,
23 23 };
@@ -156,10 +156,20 @@ pub(crate) async fn process_webhook_event(
156 156 .map_err(|e| AppError::BadRequest(format!("Failed to parse Charge: {e}")))?;
157 157 if let Some(refund_data) = ChargeRefundData::from_view(charge) {
158 158 // Direct webhook: queue as pending if the matching payment hasn't
159 - // landed yet.
159 + // landed yet. Out-of-band (dashboard) FULL refunds are handled
160 + // here; per-line refunds land via refund.created below.
160 161 billing::handle_charge_refunded(state, &refund_data, true).await?;
161 162 }
162 163 }
164 + "refund.created" | "refund.updated" => {
165 + // Line-scoped self-service refunds tag the Stripe refund with
166 + // mnw_transaction_id; this marks/revokes exactly that transaction so
167 + // a cart line refund leaves the order's other lines untouched
168 + // (Run #2 Payments SERIOUS). Untagged refunds are no-ops here.
169 + let refund: RefundView = serde_json::from_value(data_object)
170 + .map_err(|e| AppError::BadRequest(format!("Failed to parse Refund: {e}")))?;
171 + billing::handle_refund_created(state, &refund).await?;
172 + }
163 173 "customer.subscription.updated" => {
164 174 let sub: SubscriptionView = serde_json::from_value(data_object)
165 175 .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
@@ -71,6 +71,18 @@ pub struct MockPaymentProvider {
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 @@ impl MockPaymentProvider {
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 @@ impl PaymentProvider for MockPaymentProvider {
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 @@ async fn webhook_charge_refunded_no_transaction_queues_pending() {
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 // ---------------------------------------------------------------------------