max / makenotwork
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 |