Skip to main content

max / makenotwork

Fix inline item-edit price-zeroing; guard refund double-submit C1 (CRITICAL): the dashboard inline item-edit row rendered the price input with no value and a hidden price_cents defaulting to 0, so a title- or type-only save submitted price_cents=0 and COALESCE wrote it, silently setting a paid item to Free. Seed both the visible input and the hidden field from the item's existing price via ContentItem. S1 (SERIOUS): the self-service refund left the transaction Completed until the async refund.created webhook, so a rapid double-submit passed the status check twice and over-refunded a shared-cart PaymentIntent. Add a 'refunding' state and claim the row completed->refunding atomically before the Stripe call (released on Stripe error); widen the webhook finalize guard to accept it.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 03:19 UTC
Signed with PGP, not checked
Commit: 6db89d560cdcd4a44a1ed866102f07ec6cd0bc42
Parent: 1172a83
12 files changed, +188 insertions, -17 deletions
@@ -152,12 +152,17 @@
152 152 pub enum TransactionStatus {
153 153 Pending,
154 154 Completed,
155 + /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to
156 + /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards
157 + /// against double-submit on shared-cart PaymentIntents (Pay-S1, Run 9).
158 + Refunding,
155 159 Refunded,
156 160 }
157 161
158 162 impl_str_enum!(TransactionStatus {
159 163 Pending => "pending",
160 164 Completed => "completed",
165 + Refunding => "refunding",
161 166 Refunded => "refunded",
162 167 });
163 168
@@ -909,11 +909,60 @@
909 909 Ok(tx)
910 910 }
911 911
912 + /// Atomically claim a completed transaction for refund (`completed -> refunding`).
913 + ///
914 + /// Returns `Some(id)` only if THIS call won the transition; returns `None` if the
915 + /// row was not `completed` (already refunding, already refunded, or gone). The
916 + /// self-service refund handler must call this BEFORE issuing the Stripe refund so
917 + /// a rapid double-submit cannot pass the refundability check twice and over-refund
918 + /// a shared-cart PaymentIntent (Pay-S1, Run 9). On Stripe error the handler calls
919 + /// [`release_refund_claim`] to roll the row back to `completed`; on success the
920 + /// `refund.created` webhook finalizes `refunding -> refunded`.
921 + #[tracing::instrument(skip_all)]
922 + pub async fn claim_transaction_for_refund(
923 + pool: &PgPool,
924 + id: TransactionId,
925 + ) -> Result<Option<TransactionId>> {
926 + let row = sqlx::query_scalar!(
927 + r#"
928 + UPDATE transactions
929 + SET status = 'refunding'
930 + WHERE id = $1 AND status = 'completed'
931 + RETURNING id AS "id: TransactionId"
932 + "#,
933 + id as TransactionId,
934 + )
935 + .fetch_optional(pool)
936 + .await?;
937 +
938 + Ok(row)
939 + }
940 +
941 + /// Release a refund claim (`refunding -> completed`) after a Stripe refund call
942 + /// failed, so the creator can retry. Idempotent: only a row still in `refunding`
943 + /// transitions; a row the webhook already finalized to `refunded` is left alone.
944 + #[tracing::instrument(skip_all)]
945 + pub async fn release_refund_claim(pool: &PgPool, id: TransactionId) -> Result<()> {
946 + sqlx::query!(
947 + r#"
948 + UPDATE transactions
949 + SET status = 'completed'
950 + WHERE id = $1 AND status = 'refunding'
951 + "#,
952 + id as TransactionId,
953 + )
954 + .execute(pool)
955 + .await?;
956 +
957 + Ok(())
958 + }
959 +
912 960 /// Mark a transaction as refunded, returning its ID and item_id for downstream cleanup.
913 961 ///
914 - /// The WHERE clause requires `status = 'completed'` so that already-refunded
915 - /// or pending transactions are not double-processed. Returns an empty vec if no
916 - /// matching transactions were found (idempotent for webhook retries).
962 + /// The WHERE clause requires `status IN ('completed', 'refunding')` so that
963 + /// already-refunded or pending transactions are not double-processed, while a row
964 + /// the self-service handler has claimed (`refunding`) still finalizes. Returns an
965 + /// empty vec if no matching transactions were found (idempotent for webhook retries).
917 966 ///
918 967 /// Returns ALL refunded transactions (handles cart checkouts where multiple
919 968 /// transactions share the same payment_intent_id).
@@ -933,7 +982,7 @@
933 982 r#"
934 983 UPDATE transactions
935 984 SET status = 'refunded'
936 - WHERE stripe_payment_intent_id = $1 AND status = 'completed'
985 + WHERE stripe_payment_intent_id = $1 AND status IN ('completed', 'refunding')
937 986 RETURNING id AS "id: super::TransactionId", item_id AS "item_id: ItemId"
938 987 "#,
939 988 payment_intent_id,
@@ -945,8 +994,9 @@
945 994 }
946 995
947 996 /// Mark a SINGLE transaction refunded by id, returning `(id, item_id)` if it
948 - /// transitioned from `completed`. Returns `None` if it was already refunded or
949 - /// otherwise not refundable (idempotent for webhook re-delivery).
997 + /// transitioned from `completed` or `refunding` (the self-service handler claims
998 + /// the row to `refunding` before calling Stripe). Returns `None` if it was already
999 + /// refunded or otherwise not refundable (idempotent for webhook re-delivery).
950 1000 ///
951 1001 /// Used by the line-scoped `refund.created` handler: cart lines share a
952 1002 /// payment_intent, so refunding one line must touch only its own row — never the
@@ -960,7 +1010,7 @@
960 1010 r#"
961 1011 UPDATE transactions
962 1012 SET status = 'refunded'
963 - WHERE id = $1 AND status = 'completed'
1013 + WHERE id = $1 AND status IN ('completed', 'refunding')
964 1014 RETURNING id AS "id: super::TransactionId", item_id AS "item_id: ItemId"
965 1015 "#,
966 1016 id as TransactionId,
@@ -200,6 +200,12 @@
200 200 pub title: String,
201 201 pub item_type: String,
202 202 pub price: String,
203 + /// Raw price in cents, for the inline-edit form's hidden field so an
204 + /// untouched price round-trips unchanged (a blank visible input must not
205 + /// submit price_cents=0 and silently zero a paid item).
206 + pub price_cents: i32,
207 + /// Plain decimal dollars ("5.00"), for the inline-edit numeric input's value.
208 + pub price_dollars: String,
203 209 pub sales: u32,
204 210 pub revenue: String,
205 211 pub status: String,
@@ -469,6 +469,8 @@
469 469 title: item.title.clone(),
470 470 item_type: item.item_type.to_string(),
471 471 price: format_price(item.price_cents),
472 + price_cents: item.price_cents,
473 + price_dollars: crate::formatting::format_dollars_plain(item.price_cents),
472 474 sales: 0,
473 475 revenue: "$0".to_string(),
474 476 status,
@@ -13,9 +13,9 @@
13 13 </select>
14 14 </td>
15 15 <td>
16 - <input type="number" step="0.01" min="0" placeholder="0.00" class="edit-input item-edit-price-input w-80 input--numeric"
16 + <input type="number" step="0.01" min="0" placeholder="0.00" value="{{ item.price_dollars }}" class="edit-input item-edit-price-input w-80 input--numeric"
17 17 oninput="this.nextElementSibling.value = Math.round(parseFloat(this.value || 0) * 100)">
18 - <input type="hidden" name="price_cents" value="0">
18 + <input type="hidden" name="price_cents" value="{{ item.price_cents }}">
19 19 </td>
20 20 <td>{{ item.sales }}</td>
21 21 <td>{{ item.revenue }}</td>
@@ -81,6 +81,49 @@
81 81 );
82 82 }
83 83
84 + // ── db::transactions — self-service refund claim (Pay-S1, Run 9) ──
85 +
86 + #[tokio::test]
87 + async fn refund_claim_blocks_double_submit_and_releases() {
88 + let h = TestHarness::new().await;
89 + let (buyer, seller) = two_users(&h, "refclaim").await;
90 +
91 + // A bare completed transaction with a payment intent — the only state the
92 + // refund claim cares about is status = 'completed'.
93 + let tx_id: db::TransactionId = sqlx::query_scalar(
94 + "INSERT INTO transactions (buyer_id, seller_id, amount_cents, status, stripe_payment_intent_id) \
95 + VALUES ($1, $2, 500, 'completed', $3) RETURNING id",
96 + )
97 + .bind(buyer)
98 + .bind(seller)
99 + .bind("pi_refclaim_001")
100 + .fetch_one(&h.db)
101 + .await
102 + .unwrap();
103 +
104 + // First claim wins the completed -> refunding transition; a rapid second
105 + // submit (the bug: the row stays completed until the async webhook) finds
106 + // nothing and is rejected before any Stripe call.
107 + let first = db::transactions::claim_transaction_for_refund(&h.db, tx_id).await.unwrap();
108 + assert_eq!(first, Some(tx_id), "first refund claim must win completed->refunding");
109 + let second = db::transactions::claim_transaction_for_refund(&h.db, tx_id).await.unwrap();
110 + assert!(second.is_none(), "a second concurrent refund claim must be blocked (double-submit)");
111 +
112 + // A Stripe failure releases the claim back to completed so the creator can retry.
113 + db::transactions::release_refund_claim(&h.db, tx_id).await.unwrap();
114 + let reclaim = db::transactions::claim_transaction_for_refund(&h.db, tx_id).await.unwrap();
115 + assert_eq!(reclaim, Some(tx_id), "a released claim must be re-claimable");
116 +
117 + // Once the webhook finalizes the row to refunded, it is no longer claimable.
118 + sqlx::query("UPDATE transactions SET status = 'refunded' WHERE id = $1")
119 + .bind(tx_id)
120 + .execute(&h.db)
121 + .await
122 + .unwrap();
123 + let after = db::transactions::claim_transaction_for_refund(&h.db, tx_id).await.unwrap();
124 + assert!(after.is_none(), "a refunded transaction must not be refund-claimable");
125 + }
126 +
84 127 // ── db::tips — create guard + complete/refund idempotency ──
85 128
86 129 /// Two bare users (tipper, recipient) for tip tests. Raw SQL keeps the fixture
@@ -61,16 +61,30 @@
61 61 let stripe_account_id = seller.stripe_account_id.as_deref()
62 62 .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?;
63 63
64 - // Issue the line-scoped refund via Stripe — the refund.created webhook marks
65 - // and revokes exactly this transaction (cart orders share a PaymentIntent).
66 64 let stripe = state.stripe.as_ref()
67 65 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
68 - stripe.create_refund_for_transaction(
66 +
67 + // Atomically claim the row (completed -> refunding) BEFORE calling Stripe. The
68 + // status above is read from a non-locking fetch; without this claim a rapid
69 + // double-submit would pass that check twice (the row stays `completed` until the
70 + // async refund.created webhook) and, on a shared-cart PaymentIntent, the second
71 + // refund would consume another line's refundable balance (Pay-S1, Run 9).
72 + if db::transactions::claim_transaction_for_refund(&state.db, tx.id).await?.is_none() {
73 + return Err(AppError::BadRequest("A refund for this transaction is already in progress".into()));
74 + }
75 +
76 + // Issue the line-scoped refund via Stripe — the refund.created webhook marks
77 + // and revokes exactly this transaction (cart orders share a PaymentIntent). On
78 + // failure, release the claim so the creator can retry.
79 + if let Err(e) = stripe.create_refund_for_transaction(
69 80 payment_intent_id,
70 81 stripe_account_id,
71 82 tx.amount_cents.as_i64(),
72 83 tx.id,
73 - ).await?;
84 + ).await {
85 + db::transactions::release_refund_claim(&state.db, tx.id).await?;
86 + return Err(e);
87 + }
74 88
75 89 Ok(Json(serde_json::json!({ "ok": true })))
76 90 }