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
Commit: 6db89d560cdcd4a44a1ed866102f07ec6cd0bc42
Parent: 1172a83
14 files changed, +240 insertions, -69 deletions
@@ -0,0 +1,28 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n UPDATE transactions\n SET status = 'refunded'\n WHERE stripe_payment_intent_id = $1 AND status IN ('completed', 'refunding')\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 + "Text"
20 + ]
21 + },
22 + "nullable": [
23 + false,
24 + true
25 + ]
26 + },
27 + "hash": "0ed4c7b8dadd1fe7ae897775b6fa64b030834c4257ed59ee1fe58dea9e8192c1"
28 + }
@@ -0,0 +1,14 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n UPDATE transactions\n SET status = 'completed'\n WHERE id = $1 AND status = 'refunding'\n ",
4 + "describe": {
5 + "columns": [],
6 + "parameters": {
7 + "Left": [
8 + "Uuid"
9 + ]
10 + },
11 + "nullable": []
12 + },
13 + "hash": "39daf500d3809248ec3a93e1aec414f4b798b9bac32cb1d9def22809c0209990"
14 + }
@@ -1,28 +0,0 @@
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": "\n UPDATE transactions\n SET status = 'refunding'\n WHERE id = $1 AND status = 'completed'\n RETURNING id AS \"id: TransactionId\"\n ",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "id: TransactionId",
9 + "type_info": "Uuid"
10 + }
11 + ],
12 + "parameters": {
13 + "Left": [
14 + "Uuid"
15 + ]
16 + },
17 + "nullable": [
18 + false
19 + ]
20 + },
21 + "hash": "9f6d8ae25551bba4ea10c6bf422f97101bd9f1ed85bb7fedf6014203f5019e19"
22 + }
@@ -1,28 +0,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "\n UPDATE transactions\n SET status = 'refunded'\n WHERE stripe_payment_intent_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 - "Text"
20 - ]
21 - },
22 - "nullable": [
23 - false,
24 - true
25 - ]
26 - },
27 - "hash": "b827434f60da866e6ca9768614dbd2823fd7a95feea9936c30f9c867e85bd7bc"
28 - }
@@ -0,0 +1,28 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n UPDATE transactions\n SET status = 'refunded'\n WHERE id = $1 AND status IN ('completed', 'refunding')\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": "fbdc87d6123ca402837ba6006b9de02ee8a83e81837b0eccd90b627a9372bcf4"
28 + }
@@ -0,0 +1,15 @@
1 + -- Pay-S1 (ultra-fuzz Run 9): add an in-flight `refunding` state to transactions.
2 + --
3 + -- The self-service refund endpoint only checked `status = 'completed'` before
4 + -- calling Stripe, and the row stayed `completed` until the async `refund.created`
5 + -- webhook flipped it to `refunded`. A rapid double-submit therefore passed the
6 + -- guard twice; on a shared-cart PaymentIntent the second refund consumed another
7 + -- line's refundable balance. The fix claims the row `completed -> refunding`
8 + -- atomically before the Stripe call (released back to `completed` on Stripe
9 + -- error); the webhook then finalizes `refunding -> refunded`.
10 + --
11 + -- Widen the status CHECK to admit the new interim value. The constraint was
12 + -- created inline in 001_initial_schema.sql and auto-named `transactions_status_check`.
13 + ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_status_check;
14 + ALTER TABLE transactions ADD CONSTRAINT transactions_status_check
15 + CHECK (status IN ('pending', 'completed', 'failed', 'refunded', 'refunding'));
@@ -152,12 +152,17 @@ impl_str_enum!(SelectionMethod {
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 @@ pub async fn get_transaction_by_id(
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 @@ pub(crate) async fn refund_transaction_by_payment_intent<'e>(
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 @@ pub(crate) async fn refund_transaction_by_payment_intent<'e>(
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 @@ pub(crate) async fn refund_transaction_by_id<'e>(
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,
@@ -61,16 +61,30 @@ pub(in crate::routes::api) async fn refund_transaction(
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 }
@@ -200,6 +200,12 @@ pub struct ContentItem {
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 @@ impl ContentItem {
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 @@ async fn pending_refund_claimed_but_uncompleted_is_swept() {
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