max / makenotwork
9 files changed,
+340 insertions,
-15 deletions
| @@ -0,0 +1,88 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT\n id AS \"id: TipId\", tipper_id AS \"tipper_id: UserId\", recipient_id AS \"recipient_id: UserId\",\n project_id AS \"project_id: ProjectId\", amount_cents AS \"amount_cents: Cents\", message,\n status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n stripe_transfer_group,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\"\n FROM tips\n WHERE stripe_checkout_session_id = $1\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TipId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "tipper_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "recipient_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "project_id: ProjectId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "message", | |
| 34 | + | "type_info": "Varchar" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "status: super::TransactionStatus", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "stripe_payment_intent_id", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "stripe_checkout_session_id", | |
| 49 | + | "type_info": "Varchar" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "stripe_transfer_group", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 59 | + | "type_info": "Timestamptz" | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | "ordinal": 11, | |
| 63 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 64 | + | "type_info": "Timestamptz" | |
| 65 | + | } | |
| 66 | + | ], | |
| 67 | + | "parameters": { | |
| 68 | + | "Left": [ | |
| 69 | + | "Text" | |
| 70 | + | ] | |
| 71 | + | }, | |
| 72 | + | "nullable": [ | |
| 73 | + | false, | |
| 74 | + | false, | |
| 75 | + | false, | |
| 76 | + | true, | |
| 77 | + | false, | |
| 78 | + | true, | |
| 79 | + | false, | |
| 80 | + | true, | |
| 81 | + | true, | |
| 82 | + | true, | |
| 83 | + | false, | |
| 84 | + | true | |
| 85 | + | ] | |
| 86 | + | }, | |
| 87 | + | "hash": "00d9075c944110786011058b3022eaaeef8bad250de214ecb631b451b34d78f0" | |
| 88 | + | } |
| @@ -0,0 +1,21 @@ | |||
| 1 | + | -- Tip revenue-split idempotency (Run 20 Payments, chronic money-loss finding). | |
| 2 | + | -- | |
| 3 | + | -- Migration 151 gave transaction revenue splits a unique backstop | |
| 4 | + | -- (`revenue_splits_tx_recipient_key`) so `create_transaction_splits` could use | |
| 5 | + | -- ON CONFLICT DO NOTHING and a crash-recovery redelivery re-runs safely. Tip | |
| 6 | + | -- splits never got the equivalent: `create_tip_splits` was a plain INSERT, so a | |
| 7 | + | -- webhook redelivery after a crash between `complete_tip` and the split write | |
| 8 | + | -- left collaborators permanently short their share. | |
| 9 | + | -- | |
| 10 | + | -- This index is the backstop that lets `create_tip_splits` use ON CONFLICT and | |
| 11 | + | -- makes a double-split structurally impossible. Tip-split rows carry | |
| 12 | + | -- transaction_id IS NULL and tip_id IS NOT NULL; transaction-split rows are the | |
| 13 | + | -- reverse, so a full unique index on (tip_id, recipient_id) constrains only tip | |
| 14 | + | -- rows (transaction rows share the NULL tip_id, which PostgreSQL treats as | |
| 15 | + | -- distinct, so they never collide here). | |
| 16 | + | -- | |
| 17 | + | -- NON-CONCURRENT by necessity (sqlx runs each migration in its own transaction). | |
| 18 | + | -- Acceptable at current revenue_splits size; revisit if it grows large. | |
| 19 | + | CREATE UNIQUE INDEX IF NOT EXISTS revenue_splits_tip_recipient_key | |
| 20 | + | ON revenue_splits (tip_id, recipient_id) | |
| 21 | + | WHERE tip_id IS NOT NULL; |
| @@ -234,10 +234,16 @@ pub async fn create_tip_splits( | |||
| 234 | 234 | let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect(); | |
| 235 | 235 | let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a as i32).collect(); | |
| 236 | 236 | let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect(); | |
| 237 | + | // ON CONFLICT DO NOTHING (against the partial revenue_splits_tip_recipient_key, | |
| 238 | + | // migration 163): one split row per recipient per tip, so a crash-recovery | |
| 239 | + | // redelivery that re-runs `record_tip_splits` is a no-op rather than a | |
| 240 | + | // double-credit (Run 20 Payments chronic). The WHERE predicate matches the | |
| 241 | + | // partial index so PostgreSQL infers it as the arbiter. | |
| 237 | 242 | sqlx::query( | |
| 238 | 243 | r#" | |
| 239 | 244 | INSERT INTO revenue_splits (tip_id, recipient_id, amount_cents, split_percent, status) | |
| 240 | 245 | SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending' | |
| 246 | + | ON CONFLICT (tip_id, recipient_id) WHERE tip_id IS NOT NULL DO NOTHING | |
| 241 | 247 | "#, | |
| 242 | 248 | ) | |
| 243 | 249 | .bind(tip_id) |
| @@ -84,6 +84,38 @@ pub async fn complete_tip<'e>( | |||
| 84 | 84 | Ok(tip) | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | + | /// Fetch a tip by its Stripe checkout session id, regardless of status. | |
| 88 | + | /// | |
| 89 | + | /// Used for webhook crash recovery: when `complete_tip` returns `None` (the tip | |
| 90 | + | /// was already flipped to completed by an earlier delivery), the handler re-reads | |
| 91 | + | /// the tip here to re-run the idempotent split write, in case the first delivery | |
| 92 | + | /// crashed after completing the tip but before recording splits. | |
| 93 | + | #[tracing::instrument(skip(pool))] | |
| 94 | + | pub async fn get_tip_by_session( | |
| 95 | + | pool: &PgPool, | |
| 96 | + | stripe_checkout_session_id: &str, | |
| 97 | + | ) -> Result<Option<DbTip>> { | |
| 98 | + | let tip = sqlx::query_as!( | |
| 99 | + | DbTip, | |
| 100 | + | r#" | |
| 101 | + | SELECT | |
| 102 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 103 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 104 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 105 | + | stripe_transfer_group, | |
| 106 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 107 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 108 | + | FROM tips | |
| 109 | + | WHERE stripe_checkout_session_id = $1 | |
| 110 | + | "#, | |
| 111 | + | stripe_checkout_session_id, | |
| 112 | + | ) | |
| 113 | + | .fetch_optional(pool) | |
| 114 | + | .await?; | |
| 115 | + | ||
| 116 | + | Ok(tip) | |
| 117 | + | } | |
| 118 | + | ||
| 87 | 119 | /// Get tips received by a creator, most recent first. | |
| 88 | 120 | #[tracing::instrument(skip(pool))] | |
| 89 | 121 | pub async fn get_tips_received( |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | //! Webhook event retry queue; persist and retry failed webhook deliveries. | |
| 2 | 2 | ||
| 3 | - | use sqlx::PgPool; | |
| 3 | + | use sqlx::{PgPool, Postgres, Transaction}; | |
| 4 | 4 | use chrono::{DateTime, Utc}; | |
| 5 | 5 | use crate::error::Result; | |
| 6 | 6 | ||
| @@ -57,6 +57,42 @@ pub async fn mark_event_processed(pool: &PgPool, event_id: &str) -> Result<()> { | |||
| 57 | 57 | Ok(()) | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | /// Serialize concurrent redeliveries of one webhook event. | |
| 61 | + | /// | |
| 62 | + | /// Returns a transaction holding a per-event `pg_advisory_xact_lock`. Hold the | |
| 63 | + | /// returned guard across the whole dedup-read -> process -> mark sequence: a | |
| 64 | + | /// second concurrent delivery of the same event blocks in this call until the | |
| 65 | + | /// first delivery returns (releasing the lock) and, by then, has durably | |
| 66 | + | /// committed its [`mark_event_processed`] row — so the second delivery's dedup | |
| 67 | + | /// read sees it and short-circuits. | |
| 68 | + | /// | |
| 69 | + | /// This is the structural counterpart to the check-then-act dedup read. On its | |
| 70 | + | /// own that read has a TOCTOU window: two concurrent deliveries both observe | |
| 71 | + | /// "not processed" and both run the handler, so exactly-once rests entirely on | |
| 72 | + | /// every handler's own idempotency. With this lock held, the read is race-free | |
| 73 | + | /// and concurrent double-processing is impossible — a future non-idempotent | |
| 74 | + | /// handler cannot double-fire on a redelivery race. | |
| 75 | + | /// | |
| 76 | + | /// Robustness: the lock is transaction-scoped, so dropping the guard on any | |
| 77 | + | /// early return, `?`, or panic rolls the (write-free) transaction back and | |
| 78 | + | /// releases the lock. It cannot leak the way a pooled session lock would. The | |
| 79 | + | /// key is namespaced (`stripe_webhook:` prefix) so it shares no space with the | |
| 80 | + | /// other `hashtextextended` advisory locks in the codebase (reports, oauth). | |
| 81 | + | #[tracing::instrument(skip_all)] | |
| 82 | + | pub async fn lock_event<'a>( | |
| 83 | + | pool: &'a PgPool, | |
| 84 | + | event_id: &str, | |
| 85 | + | ) -> Result<Transaction<'a, Postgres>> { | |
| 86 | + | let mut tx = pool.begin().await?; | |
| 87 | + | sqlx::query( | |
| 88 | + | "SELECT pg_advisory_xact_lock(hashtextextended('stripe_webhook:' || $1::text, 0))", | |
| 89 | + | ) | |
| 90 | + | .bind(event_id) | |
| 91 | + | .execute(&mut *tx) | |
| 92 | + | .await?; | |
| 93 | + | Ok(tx) | |
| 94 | + | } | |
| 95 | + | ||
| 60 | 96 | /// Delete processed-event dedup markers older than `days`. These markers only | |
| 61 | 97 | /// guard against Stripe *redelivering* an event, which it stops doing within a | |
| 62 | 98 | /// few days; 30 days is the retention the table was created with (migration |
| @@ -639,7 +639,26 @@ pub(super) async fn handle_tip_checkout_completed( | |||
| 639 | 639 | send_tip_email(state, &tip, tipper_id, recipient_id); | |
| 640 | 640 | } | |
| 641 | 641 | None => { | |
| 642 | - | tracing::info!(session_id = %session_id, "tip already completed, ignoring duplicate webhook"); | |
| 642 | + | // No pending row flipped to completed. Either a benign duplicate of | |
| 643 | + | // an already-finalized tip, OR the first delivery crashed AFTER | |
| 644 | + | // completing the tip but BEFORE recording splits — in which case | |
| 645 | + | // collaborators hold a completed tip with no split rows. Re-fetch the | |
| 646 | + | // tip and re-run the idempotent split write (ON CONFLICT DO NOTHING, | |
| 647 | + | // migration 163); a genuine duplicate is a no-op. Run 20 Payments. | |
| 648 | + | let recovered = db::tips::get_tip_by_session(&state.db, &session_id) | |
| 649 | + | .await | |
| 650 | + | .context("re-fetch tip for crash recovery")?; | |
| 651 | + | if let Some(tip) = recovered | |
| 652 | + | && let Some(project_id) = tip.project_id | |
| 653 | + | { | |
| 654 | + | tracing::info!( | |
| 655 | + | session_id = %session_id, tip_id = %tip.id, | |
| 656 | + | "crash-recovery: re-running tip splits for already-completed tip" | |
| 657 | + | ); | |
| 658 | + | record_tip_splits(state, tip.id, project_id, tip.amount_cents).await; | |
| 659 | + | } else { | |
| 660 | + | tracing::info!(session_id = %session_id, "tip already completed, ignoring duplicate webhook"); | |
| 661 | + | } | |
| 643 | 662 | } | |
| 644 | 663 | } | |
| 645 | 664 |
| @@ -45,21 +45,29 @@ pub(in crate::routes::stripe) async fn webhook( | |||
| 45 | 45 | let event = stripe.verify_webhook(payload, signature)?; | |
| 46 | 46 | tracing::info!(event_type = %event.type_, event_id = %event.id, "received webhook event"); | |
| 47 | 47 | ||
| 48 | + | // Serialize concurrent redeliveries of this event id. Held across the whole | |
| 49 | + | // dedup-read -> process -> mark sequence below, the lock makes the dedup read | |
| 50 | + | // race-free: a second delivery of the same event blocks until this one | |
| 51 | + | // returns and has durably committed its processed-event mark, then reads it | |
| 52 | + | // and skips. Dropping `_event_lock` on any return path rolls the (write-free) | |
| 53 | + | // lock transaction back and releases it — it cannot leak. See | |
| 54 | + | // `db::webhook_events::lock_event`. On acquisition failure we return 503 so | |
| 55 | + | // Stripe retries, matching the dedup-read failure path. | |
| 56 | + | let _event_lock = match db::webhook_events::lock_event(&state.db, &event.id).await { | |
| 57 | + | Ok(tx) => tx, | |
| 58 | + | Err(e) => { | |
| 59 | + | tracing::error!(event_id = %event.id, error = ?e, "failed to acquire webhook event lock, returning 503 for retry"); | |
| 60 | + | return Ok(StatusCode::SERVICE_UNAVAILABLE); | |
| 61 | + | } | |
| 62 | + | }; | |
| 63 | + | ||
| 48 | 64 | // Deduplicate: skip if we already processed this event ID. This is a READ; | |
| 49 | 65 | // the "processed" row is written only after the handler succeeds (below), so | |
| 50 | - | // a crash mid-processing leaves no marker and Stripe's redelivery reprocesses | |
| 51 | - | // (handlers are idempotent). The old "mark before processing" ordering could | |
| 52 | - | // strand an event whose process died after the mark committed but before its | |
| 53 | - | // work or retry row landed. | |
| 54 | - | // | |
| 55 | - | // INVARIANT (load-bearing): this dedup is check-then-act, so two concurrent | |
| 56 | - | // deliveries of the same event both reach `process_webhook_event`. Safety | |
| 57 | - | // therefore rests entirely on every handler's side-effects being atomic and | |
| 58 | - | // idempotent — a status-guarded UPDATE, an ON CONFLICT write, or a money | |
| 59 | - | // mint that sits behind its own claim witness (see `FanPlusCreditClaim` in | |
| 60 | - | // `db::promo_codes`). A *non-idempotent* side-effect (sending an email, | |
| 61 | - | // calling an external API) MUST be gated behind such a claim, never run | |
| 62 | - | // before its atomic write — otherwise a redelivery double-fires it. | |
| 66 | + | // a crash mid-processing leaves no marker and Stripe's redelivery reprocesses. | |
| 67 | + | // The `_event_lock` above closes the check-then-act race, so this read now | |
| 68 | + | // guarantees exactly-once dispatch regardless of handler idempotency; per- | |
| 69 | + | // handler atomic guards (status-guarded UPDATEs, ON CONFLICT writes, the | |
| 70 | + | // `FanPlusCreditClaim` witness) remain as defense in depth. | |
| 63 | 71 | match db::webhook_events::is_event_processed(&state.db, &event.id).await { | |
| 64 | 72 | Ok(true) => { | |
| 65 | 73 | tracing::info!(event_id = %event.id, "duplicate webhook event, skipping"); |
| @@ -48,6 +48,19 @@ pub(super) async fn webhook_v2( | |||
| 48 | 48 | ||
| 49 | 49 | tracing::info!(event_type = %thin.event_type, event_id = %thin.id, "received v2 thin event"); | |
| 50 | 50 | ||
| 51 | + | // Serialize concurrent redeliveries of this event id (see | |
| 52 | + | // `db::webhook_events::lock_event`). Held across dedup-read -> process -> | |
| 53 | + | // mark, it makes the check-then-act read below race-free. Dropping | |
| 54 | + | // `_event_lock` on any return releases the lock; on acquisition failure we | |
| 55 | + | // return 503 so Stripe retries. | |
| 56 | + | let _event_lock = match db::webhook_events::lock_event(&state.db, &thin.id).await { | |
| 57 | + | Ok(tx) => tx, | |
| 58 | + | Err(e) => { | |
| 59 | + | tracing::error!(event_id = %thin.id, error = ?e, "failed to acquire v2 webhook event lock, returning 503 for retry"); | |
| 60 | + | return Ok(StatusCode::SERVICE_UNAVAILABLE); | |
| 61 | + | } | |
| 62 | + | }; | |
| 63 | + | ||
| 51 | 64 | // Deduplicate: skip if this event was already processed. Read-only; the | |
| 52 | 65 | // "processed" row is written only after the handler succeeds (below), so a | |
| 53 | 66 | // crash mid-processing leaves no marker and Stripe redelivers (the handler |
| @@ -182,6 +182,108 @@ async fn crash_recovery_reruns_finalize_idempotently() { | |||
| 182 | 182 | assert_eq!(split_count, 1, "idempotent: still exactly one split"); | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | + | /// Deliver a `checkout.session.completed` webhook for a tip on `session_id`. | |
| 186 | + | async fn deliver_tip_webhook( | |
| 187 | + | h: &mut TestHarness, | |
| 188 | + | event_id: &str, | |
| 189 | + | session_id: &str, | |
| 190 | + | tipper_id: db::UserId, | |
| 191 | + | recipient_id: db::UserId, | |
| 192 | + | ) -> u16 { | |
| 193 | + | let mut meta = HashMap::new(); | |
| 194 | + | meta.insert("checkout_type".to_string(), "tip".to_string()); | |
| 195 | + | meta.insert("tipper_id".to_string(), tipper_id.to_string()); | |
| 196 | + | meta.insert("recipient_id".to_string(), recipient_id.to_string()); | |
| 197 | + | let session = serde_json::json!({ | |
| 198 | + | "id": session_id, | |
| 199 | + | "object": "checkout_session", | |
| 200 | + | "mode": "payment", | |
| 201 | + | "metadata": meta, | |
| 202 | + | "payment_intent": "pi_tip_crash_recovery", | |
| 203 | + | }); | |
| 204 | + | let payload = serde_json::json!({ | |
| 205 | + | "id": event_id, | |
| 206 | + | "type": "checkout.session.completed", | |
| 207 | + | "data": {"object": session}, | |
| 208 | + | }) | |
| 209 | + | .to_string(); | |
| 210 | + | let signature = crate::harness::stripe::sign_webhook_payload( | |
| 211 | + | &payload, | |
| 212 | + | crate::harness::stripe::TEST_WEBHOOK_SECRET, | |
| 213 | + | ); | |
| 214 | + | h.client.request_with_headers( | |
| 215 | + | "POST", | |
| 216 | + | "/stripe/webhook", | |
| 217 | + | Some(&payload), | |
| 218 | + | &[ | |
| 219 | + | ("stripe-signature", &signature), | |
| 220 | + | ("content-type", "application/json"), | |
| 221 | + | ], | |
| 222 | + | ).await.status.as_u16() | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// Tip revenue splits must survive a crash between `complete_tip` and the split | |
| 226 | + | /// write, and a redelivery must not double-credit collaborators (Run 20 chronic | |
| 227 | + | /// money-loss). Mirrors the purchase recovery test for the tip path. | |
| 228 | + | #[tokio::test] | |
| 229 | + | async fn tip_split_crash_recovery_is_idempotent() { | |
| 230 | + | let mut h = TestHarness::with_mocks().await; | |
| 231 | + | ||
| 232 | + | // Recipient (creator) with a project and a 50% collaborator. | |
| 233 | + | let recipient_id = h.signup("trecip", "trecip@test.com", "pass1234").await; | |
| 234 | + | let project_id: db::ProjectId = sqlx::query_scalar( | |
| 235 | + | "INSERT INTO projects (user_id, slug, title) VALUES ($1, 'tshop', 'TShop') RETURNING id", | |
| 236 | + | ).bind(recipient_id).fetch_one(&h.db).await.unwrap(); | |
| 237 | + | let collab_id = h.signup("tcollab", "tcollab@test.com", "pass1234").await; | |
| 238 | + | sqlx::query( | |
| 239 | + | "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1, $2, 'member', 50, $3)", | |
| 240 | + | ) | |
| 241 | + | .bind(project_id) | |
| 242 | + | .bind(collab_id) | |
| 243 | + | .bind(recipient_id) | |
| 244 | + | .execute(&h.db) | |
| 245 | + | .await | |
| 246 | + | .unwrap(); | |
| 247 | + | ||
| 248 | + | // A tipper and a pending tip attributed to the project. | |
| 249 | + | let tipper_id = h.signup("ttipper", "ttipper@test.com", "pass1234").await; | |
| 250 | + | let session_id = "cs_tip_crash_001"; | |
| 251 | + | db::tips::create_tip(&h.db, tipper_id, recipient_id, Some(project_id), 1000, None, session_id) | |
| 252 | + | .await | |
| 253 | + | .unwrap(); | |
| 254 | + | let tip_id: db::TipId = sqlx::query_scalar( | |
| 255 | + | "SELECT id FROM tips WHERE stripe_checkout_session_id = $1", | |
| 256 | + | ).bind(session_id).fetch_one(&h.db).await.unwrap(); | |
| 257 | + | ||
| 258 | + | // First delivery completes the tip and records the split. | |
| 259 | + | let status = deliver_tip_webhook(&mut h, "evt_tip_1", session_id, tipper_id, recipient_id).await; | |
| 260 | + | assert_eq!(status, 200, "first tip webhook should succeed"); | |
| 261 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | |
| 262 | + | let split_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1") | |
| 263 | + | .bind(tip_id).fetch_one(&h.db).await.unwrap(); | |
| 264 | + | assert_eq!(split_count, 1, "first delivery records the collaborator split"); | |
| 265 | + | ||
| 266 | + | // Simulate a crash before the split write: tip stays completed, split gone. | |
| 267 | + | sqlx::query("DELETE FROM revenue_splits WHERE tip_id = $1").bind(tip_id).execute(&h.db).await.unwrap(); | |
| 268 | + | ||
| 269 | + | // Redelivery (new event id): complete_tip returns None; the recovery branch | |
| 270 | + | // re-fetches the tip and re-runs the idempotent split write. | |
| 271 | + | let status = deliver_tip_webhook(&mut h, "evt_tip_2", session_id, tipper_id, recipient_id).await; | |
| 272 | + | assert_eq!(status, 200, "recovery tip webhook should succeed"); | |
| 273 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | |
| 274 | + | let split_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1") | |
| 275 | + | .bind(tip_id).fetch_one(&h.db).await.unwrap(); | |
| 276 | + | assert_eq!(split_count, 1, "recovery re-records the missing tip split"); | |
| 277 | + | ||
| 278 | + | // A second recovery must NOT duplicate (ON CONFLICT DO NOTHING, migration 163). | |
| 279 | + | let status = deliver_tip_webhook(&mut h, "evt_tip_3", session_id, tipper_id, recipient_id).await; | |
| 280 | + | assert_eq!(status, 200, "second recovery tip webhook should succeed"); | |
| 281 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | |
| 282 | + | let split_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1") | |
| 283 | + | .bind(tip_id).fetch_one(&h.db).await.unwrap(); | |
| 284 | + | assert_eq!(split_count, 1, "idempotent: still exactly one tip split"); | |
| 285 | + | } | |
| 286 | + | ||
| 185 | 287 | /// The partial unique index on license_keys (transaction_id) structurally rejects | |
| 186 | 288 | /// a second auto-minted key for the same transaction even if the pre-check is | |
| 187 | 289 | /// bypassed. |