max / makenotwork
4 files changed,
+319 insertions,
-97 deletions
| @@ -132,6 +132,30 @@ pub struct CheckoutSessionView { | |||
| 132 | 132 | /// items; absent on older/edge events, hence `Option`. | |
| 133 | 133 | #[serde(default)] | |
| 134 | 134 | pub amount_subtotal: Option<i64>, | |
| 135 | + | /// Whether Stripe has captured funds for this session: `"paid"`, | |
| 136 | + | /// `"unpaid"`, or `"no_payment_required"`. Synchronous card payments report | |
| 137 | + | /// `"paid"` on `checkout.session.completed`; asynchronous methods (ACH, | |
| 138 | + | /// SEPA, Bacs) report `"unpaid"` there and settle later via | |
| 139 | + | /// `checkout.session.async_payment_succeeded`. Absent on older/edge events, | |
| 140 | + | /// hence `Option` — see `payment_settled`. | |
| 141 | + | #[serde(default)] | |
| 142 | + | pub payment_status: Option<String>, | |
| 143 | + | /// ISO currency of the session (e.g. `"usd"`). Sessions are built | |
| 144 | + | /// server-side as USD; a non-USD value makes the integer-cents subtotal | |
| 145 | + | /// reconciliation meaningless and is itself an anomaly. Absent on | |
| 146 | + | /// older/edge events, hence `Option`. | |
| 147 | + | #[serde(default)] | |
| 148 | + | pub currency: Option<String>, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | impl CheckoutSessionView { | |
| 152 | + | /// True when funds are captured (or none were required) and it is safe to | |
| 153 | + | /// deliver goods. Treats an absent field as settled to preserve behaviour | |
| 154 | + | /// for legacy/edge events that predate the field; only an explicit | |
| 155 | + | /// `"unpaid"` (an async method awaiting settlement) is withheld. | |
| 156 | + | pub fn payment_settled(&self) -> bool { | |
| 157 | + | matches!(self.payment_status.as_deref(), None | Some("paid") | Some("no_payment_required")) | |
| 158 | + | } | |
| 135 | 159 | } | |
| 136 | 160 | ||
| 137 | 161 | #[derive(Debug, Default, serde::Deserialize)] | |
| @@ -473,6 +497,54 @@ mod tests { | |||
| 473 | 497 | assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment); | |
| 474 | 498 | } | |
| 475 | 499 | ||
| 500 | + | // --- CheckoutSessionView payment settlement gate --- | |
| 501 | + | ||
| 502 | + | fn view_with_status(status: Option<&str>) -> CheckoutSessionView { | |
| 503 | + | CheckoutSessionView { | |
| 504 | + | payment_status: status.map(str::to_string), | |
| 505 | + | ..Default::default() | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | #[test] | |
| 510 | + | fn payment_settled_true_for_paid_and_no_payment_required() { | |
| 511 | + | assert!(view_with_status(Some("paid")).payment_settled()); | |
| 512 | + | assert!(view_with_status(Some("no_payment_required")).payment_settled()); | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | #[test] | |
| 516 | + | fn payment_settled_false_only_for_explicit_unpaid() { | |
| 517 | + | // The async-method case: `checkout.session.completed` arrives with | |
| 518 | + | // "unpaid" and goods must NOT be delivered until settlement. | |
| 519 | + | assert!(!view_with_status(Some("unpaid")).payment_settled()); | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | #[test] | |
| 523 | + | fn payment_settled_true_when_absent_preserves_legacy_behaviour() { | |
| 524 | + | // Older/edge events without the field must still finalize (synchronous | |
| 525 | + | // card sessions predating the field, and any event Stripe omits it on). | |
| 526 | + | assert!(view_with_status(None).payment_settled()); | |
| 527 | + | assert!(!view_with_status(Some("something_new")).payment_settled()); | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | #[test] | |
| 531 | + | fn payment_status_and_currency_deserialize_from_session_json() { | |
| 532 | + | let session: CheckoutSessionView = serde_json::from_value(json!({ | |
| 533 | + | "id": "cs_1", | |
| 534 | + | "payment_status": "unpaid", | |
| 535 | + | "currency": "usd", | |
| 536 | + | })).unwrap(); | |
| 537 | + | assert_eq!(session.payment_status.as_deref(), Some("unpaid")); | |
| 538 | + | assert_eq!(session.currency.as_deref(), Some("usd")); | |
| 539 | + | assert!(!session.payment_settled()); | |
| 540 | + | ||
| 541 | + | // Absent fields default to None (settled). | |
| 542 | + | let bare: CheckoutSessionView = serde_json::from_value(json!({"id": "cs_2"})).unwrap(); | |
| 543 | + | assert!(bare.payment_status.is_none()); | |
| 544 | + | assert!(bare.currency.is_none()); | |
| 545 | + | assert!(bare.payment_settled()); | |
| 546 | + | } | |
| 547 | + | ||
| 476 | 548 | // Subscription parses with current_period_* on items.data[0]. | |
| 477 | 549 | #[test] | |
| 478 | 550 | fn subscription_parses_from_fixture_with_items_period() { |
| @@ -52,6 +52,66 @@ async fn escalate_if_orphaned_session( | |||
| 52 | 52 | Ok(()) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | + | /// Defense-in-depth reconciliation of a completed checkout against Stripe's | |
| 56 | + | /// reported session totals. Our line items are server-built, so the credited | |
| 57 | + | /// total should equal Stripe's pre-tax subtotal and the session should be USD. | |
| 58 | + | /// A currency mismatch or an amount mismatch is logged loudly and escalated to | |
| 59 | + | /// WAM (fire-and-forget so it never runs inside an open DB transaction); the | |
| 60 | + | /// server-recorded amount stays authoritative either way. Shared by the | |
| 61 | + | /// purchase, cart, and guest completion handlers so the three can't drift. | |
| 62 | + | fn reconcile_checkout_amount( | |
| 63 | + | state: &AppState, | |
| 64 | + | session_id: &str, | |
| 65 | + | session: &crate::payments::CheckoutSessionView, | |
| 66 | + | credited_cents: i64, | |
| 67 | + | label: &str, | |
| 68 | + | ) { | |
| 69 | + | // Currency guard: a non-USD session makes the integer-cents subtotal | |
| 70 | + | // comparison meaningless and should never happen (sessions are built USD). | |
| 71 | + | if let Some(currency) = session.currency.as_deref() | |
| 72 | + | && !currency.eq_ignore_ascii_case("usd") | |
| 73 | + | { | |
| 74 | + | tracing::error!( | |
| 75 | + | session_id = %session_id, currency = %currency, | |
| 76 | + | "checkout session currency is not USD ({label}); integer-cents reconciliation skipped" | |
| 77 | + | ); | |
| 78 | + | if let Some(wam) = state.wam.clone() { | |
| 79 | + | let session_id = session_id.to_string(); | |
| 80 | + | let currency = currency.to_string(); | |
| 81 | + | let label = label.to_string(); | |
| 82 | + | tokio::spawn(async move { | |
| 83 | + | let body = format!( | |
| 84 | + | "Checkout session {session_id} ({label}) settled in {currency}, not USD. The \ | |
| 85 | + | server credits its own USD amount, but investigate how a non-USD session was created." | |
| 86 | + | ); | |
| 87 | + | wam.create_ticket("Non-USD checkout session", Some(&body), "high", "stripe-non-usd-session", Some(&session_id)).await; | |
| 88 | + | }); | |
| 89 | + | } | |
| 90 | + | return; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | if let Some(subtotal) = session.amount_subtotal | |
| 94 | + | && subtotal != credited_cents | |
| 95 | + | { | |
| 96 | + | tracing::error!( | |
| 97 | + | session_id = %session_id, credited_cents = %credited_cents, stripe_subtotal_cents = %subtotal, | |
| 98 | + | "checkout amount mismatch ({label}): credited amount differs from Stripe session subtotal" | |
| 99 | + | ); | |
| 100 | + | if let Some(wam) = state.wam.clone() { | |
| 101 | + | let session_id = session_id.to_string(); | |
| 102 | + | let label = label.to_string(); | |
| 103 | + | tokio::spawn(async move { | |
| 104 | + | let body = format!( | |
| 105 | + | "Credited amount {credited_cents} cents != Stripe session subtotal {subtotal} cents \ | |
| 106 | + | (session {session_id}, {label}). The server amount is authoritative; investigate a \ | |
| 107 | + | price-edit / Stripe Tax / currency edge." | |
| 108 | + | ); | |
| 109 | + | wam.create_ticket("Checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await; | |
| 110 | + | }); | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 55 | 115 | /// Handle checkout.session.completed for one-time purchases | |
| 56 | 116 | #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")] | |
| 57 | 117 | pub(super) async fn handle_purchase_checkout_completed( | |
| @@ -90,34 +150,9 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 90 | 150 | "transaction completed" | |
| 91 | 151 | ); | |
| 92 | 152 | ||
| 93 | - | // Defense-in-depth reconciliation: our line items are server-built, | |
| 94 | - | // so Stripe's pre-tax subtotal should equal the amount we credit. A | |
| 95 | - | // mismatch (a future Stripe Tax / price-edit / currency edge) is | |
| 96 | - | // logged loudly rather than silently trusted — we still credit the | |
| 97 | - | // server amount, which is authoritative. | |
| 98 | - | if let Some(subtotal) = session.amount_subtotal | |
| 99 | - | && subtotal != i64::from(tx.amount_cents) | |
| 100 | - | { | |
| 101 | - | tracing::error!( | |
| 102 | - | session_id = %session_id, credited_cents = %tx.amount_cents, stripe_subtotal_cents = %subtotal, | |
| 103 | - | "checkout amount mismatch: credited transaction amount differs from Stripe session subtotal" | |
| 104 | - | ); | |
| 105 | - | // Escalate like every other money anomaly. Fire-and-forget on a | |
| 106 | - | // spawned task so the external WAM call doesn't run inside (and | |
| 107 | - | // hold the connection for) the open `db_tx`. | |
| 108 | - | if let Some(wam) = state.wam.clone() { | |
| 109 | - | let session_id = session_id.to_string(); | |
| 110 | - | let credited = tx.amount_cents; | |
| 111 | - | tokio::spawn(async move { | |
| 112 | - | let body = format!( | |
| 113 | - | "Credited amount {credited} cents != Stripe session subtotal {subtotal} cents \ | |
| 114 | - | (session {session_id}). The server amount is authoritative; investigate a \ | |
| 115 | - | price-edit / Stripe Tax / currency edge." | |
| 116 | - | ); | |
| 117 | - | wam.create_ticket("Checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await; | |
| 118 | - | }); | |
| 119 | - | } | |
| 120 | - | } | |
| 153 | + | // Defense-in-depth reconciliation (currency + subtotal) against the | |
| 154 | + | // server-authoritative credited amount. | |
| 155 | + | reconcile_checkout_amount(state, &session_id, session, i64::from(tx.amount_cents), "purchase"); | |
| 121 | 156 | ||
| 122 | 157 | // Increment denormalized sales_count (inside transaction) | |
| 123 | 158 | if let Some(iid) = item_id { | |
| @@ -235,31 +270,10 @@ pub(super) async fn handle_cart_checkout_completed( | |||
| 235 | 270 | count = completed_txs.len(), "cart transactions completed" | |
| 236 | 271 | ); | |
| 237 | 272 | ||
| 238 | - | // Defense-in-depth reconciliation: line items are server-built, so the sum | |
| 239 | - | // of the credited transactions should equal Stripe's pre-tax subtotal. A | |
| 240 | - | // mismatch (future Stripe Tax / price-edit / currency edge) is logged loudly | |
| 241 | - | // rather than silently trusted — the server amounts remain authoritative. | |
| 242 | - | if let Some(subtotal) = session.amount_subtotal { | |
| 243 | - | let credited: i64 = completed_txs.iter().map(|tx| i64::from(tx.amount_cents)).sum(); | |
| 244 | - | if credited != subtotal { | |
| 245 | - | tracing::error!( | |
| 246 | - | session_id = %session_id, credited_cents = %credited, stripe_subtotal_cents = %subtotal, | |
| 247 | - | "cart checkout amount mismatch: sum of credited transactions differs from Stripe session subtotal" | |
| 248 | - | ); | |
| 249 | - | // Escalate like every other money anomaly, off the open db_tx. | |
| 250 | - | if let Some(wam) = state.wam.clone() { | |
| 251 | - | let session_id = session_id.to_string(); | |
| 252 | - | tokio::spawn(async move { | |
| 253 | - | let body = format!( | |
| 254 | - | "Sum of credited cart transactions {credited} cents != Stripe session subtotal \ | |
| 255 | - | {subtotal} cents (session {session_id}). Server amounts are authoritative; \ | |
| 256 | - | investigate a price-edit / Stripe Tax / currency edge." | |
| 257 | - | ); | |
| 258 | - | wam.create_ticket("Cart checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await; | |
| 259 | - | }); | |
| 260 | - | } | |
| 261 | - | } | |
| 262 | - | } | |
| 273 | + | // Defense-in-depth reconciliation (currency + subtotal): the sum of the | |
| 274 | + | // credited transactions should equal Stripe's pre-tax subtotal. | |
| 275 | + | let cart_credited: i64 = completed_txs.iter().map(|tx| i64::from(tx.amount_cents)).sum(); | |
| 276 | + | reconcile_checkout_amount(state, &session_id, session, cart_credited, "cart"); | |
| 263 | 277 | ||
| 264 | 278 | // Increment sales count for each item | |
| 265 | 279 | for tx in &completed_txs { | |
| @@ -684,30 +698,9 @@ pub(super) async fn handle_guest_checkout_completed( | |||
| 684 | 698 | "guest transaction completed" | |
| 685 | 699 | ); | |
| 686 | 700 | ||
| 687 | - | // Defense-in-depth reconciliation, mirroring the single-item and | |
| 688 | - | // cart paths: our line items are server-built, so Stripe's pre-tax | |
| 689 | - | // subtotal should equal what we credit. A mismatch is logged loudly | |
| 690 | - | // and escalated; the server amount stays authoritative. | |
| 691 | - | if let Some(subtotal) = session.amount_subtotal | |
| 692 | - | && subtotal != i64::from(tx.amount_cents) | |
| 693 | - | { | |
| 694 | - | tracing::error!( | |
| 695 | - | session_id = %session_id, credited_cents = %tx.amount_cents, stripe_subtotal_cents = %subtotal, | |
| 696 | - | "guest checkout amount mismatch: credited transaction amount differs from Stripe session subtotal" | |
| 697 | - | ); | |
| 698 | - | if let Some(wam) = state.wam.clone() { | |
| 699 | - | let session_id = session_id.to_string(); | |
| 700 | - | let credited = tx.amount_cents; | |
| 701 | - | tokio::spawn(async move { | |
| 702 | - | let body = format!( | |
| 703 | - | "Credited amount {credited} cents != Stripe session subtotal {subtotal} cents \ | |
| 704 | - | (guest session {session_id}). The server amount is authoritative; investigate a \ | |
| 705 | - | price-edit / Stripe Tax / currency edge." | |
| 706 | - | ); | |
| 707 | - | wam.create_ticket("Guest checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await; | |
| 708 | - | }); | |
| 709 | - | } | |
| 710 | - | } | |
| 701 | + | // Defense-in-depth reconciliation (currency + subtotal), mirroring | |
| 702 | + | // the single-item and cart paths. | |
| 703 | + | reconcile_checkout_amount(state, &session_id, session, i64::from(tx.amount_cents), "guest"); | |
| 711 | 704 | ||
| 712 | 705 | // Increment sales count inside transaction | |
| 713 | 706 | db::items::increment_sales_count(&mut *db_tx, meta.item_id) |
| @@ -124,27 +124,26 @@ pub(crate) async fn process_webhook_event( | |||
| 124 | 124 | data_object: serde_json::Value, | |
| 125 | 125 | ) -> Result<()> { | |
| 126 | 126 | match event_type { | |
| 127 | - | "checkout.session.completed" => { | |
| 127 | + | // Both events route through the same dispatcher. `completed` fires | |
| 128 | + | // immediately; for asynchronous payment methods (ACH/SEPA/Bacs) it | |
| 129 | + | // arrives with payment_status="unpaid" and the money-taking handlers | |
| 130 | + | // defer until `async_payment_succeeded` re-delivers the settled session. | |
| 131 | + | "checkout.session.completed" | "checkout.session.async_payment_succeeded" => { | |
| 128 | 132 | let session: CheckoutSessionView = serde_json::from_value(data_object) | |
| 129 | 133 | .map_err(|e| AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}")))?; | |
| 130 | - | let meta = session.metadata.as_ref(); | |
| 131 | - | if payments::is_fan_plus_checkout(meta) { | |
| 132 | - | checkout::handle_fan_plus_checkout_completed(state, &session, event_id).await?; | |
| 133 | - | } else if payments::is_creator_tier_checkout(meta) { | |
| 134 | - | checkout::handle_creator_tier_checkout_completed(state, &session, event_id).await?; | |
| 135 | - | } else if payments::is_synckit_app_sub_checkout(meta) { | |
| 136 | - | checkout::handle_synckit_app_sub_checkout_completed(state, &session, event_id).await?; | |
| 137 | - | } else if payments::is_tip_checkout(meta) { | |
| 138 | - | checkout::handle_tip_checkout_completed(state, &session, event_id).await?; | |
| 139 | - | } else if payments::is_subscription_checkout(meta) { | |
| 140 | - | checkout::handle_subscription_checkout_completed(state, &session, event_id).await?; | |
| 141 | - | } else if payments::is_guest_checkout(meta) { | |
| 142 | - | checkout::handle_guest_checkout_completed(state, &session, event_id).await?; | |
| 143 | - | } else if payments::is_cart_checkout(meta) { | |
| 144 | - | checkout::handle_cart_checkout_completed(state, &session, event_id).await?; | |
| 145 | - | } else { | |
| 146 | - | checkout::handle_purchase_checkout_completed(state, &session, event_id).await?; | |
| 147 | - | } | |
| 134 | + | dispatch_checkout_session(state, &session, event_id).await?; | |
| 135 | + | } | |
| 136 | + | // The buyer's async payment (ACH/SEPA/Bacs) never cleared. No funds were | |
| 137 | + | // captured, so there is nothing to deliver; the pending transaction (and | |
| 138 | + | // any reserved promo hold) is released by the stale-pending cleanup | |
| 139 | + | // sweeper. Logged for visibility rather than silently dropped. | |
| 140 | + | "checkout.session.async_payment_failed" => { | |
| 141 | + | let session: CheckoutSessionView = serde_json::from_value(data_object) | |
| 142 | + | .map_err(|e| AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}")))?; | |
| 143 | + | tracing::warn!( | |
| 144 | + | session_id = %session.id, | |
| 145 | + | "checkout async payment failed; no funds captured, pending rows will be released by cleanup" | |
| 146 | + | ); | |
| 148 | 147 | } | |
| 149 | 148 | "account.updated" => { | |
| 150 | 149 | let account: AccountView = serde_json::from_value(data_object) | |
| @@ -198,6 +197,59 @@ pub(crate) async fn process_webhook_event( | |||
| 198 | 197 | Ok(()) | |
| 199 | 198 | } | |
| 200 | 199 | ||
| 200 | + | /// Route a checkout session to its handler by metadata shape. | |
| 201 | + | /// | |
| 202 | + | /// Subscription-mode sessions (Fan+, creator tier, SyncKit app sub, project | |
| 203 | + | /// subscription) capture no funds at checkout — the subscription lifecycle bills | |
| 204 | + | /// separately — so they run unconditionally. One-time payment-mode sessions | |
| 205 | + | /// (tip, guest, cart, single purchase) capture funds now and are therefore | |
| 206 | + | /// gated on `payment_settled()`: an async method that reports `payment_status | |
| 207 | + | /// = "unpaid"` on `checkout.session.completed` is deferred until Stripe | |
| 208 | + | /// re-delivers the settled session via `checkout.session.async_payment_succeeded`. | |
| 209 | + | /// Without this gate, enabling any async payment method on a connected account | |
| 210 | + | /// would mint license keys and grant downloads before funds settle. | |
| 211 | + | async fn dispatch_checkout_session( | |
| 212 | + | state: &AppState, | |
| 213 | + | session: &CheckoutSessionView, | |
| 214 | + | event_id: &str, | |
| 215 | + | ) -> Result<()> { | |
| 216 | + | let meta = session.metadata.as_ref(); | |
| 217 | + | ||
| 218 | + | // Subscription-mode: no funds captured at checkout, no settlement gate. | |
| 219 | + | if payments::is_fan_plus_checkout(meta) { | |
| 220 | + | return checkout::handle_fan_plus_checkout_completed(state, session, event_id).await; | |
| 221 | + | } | |
| 222 | + | if payments::is_creator_tier_checkout(meta) { | |
| 223 | + | return checkout::handle_creator_tier_checkout_completed(state, session, event_id).await; | |
| 224 | + | } | |
| 225 | + | if payments::is_synckit_app_sub_checkout(meta) { | |
| 226 | + | return checkout::handle_synckit_app_sub_checkout_completed(state, session, event_id).await; | |
| 227 | + | } | |
| 228 | + | if payments::is_subscription_checkout(meta) { | |
| 229 | + | return checkout::handle_subscription_checkout_completed(state, session, event_id).await; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // One-time payment-mode: funds captured now. Deliver only once settled. | |
| 233 | + | if !session.payment_settled() { | |
| 234 | + | tracing::info!( | |
| 235 | + | session_id = %session.id, | |
| 236 | + | payment_status = ?session.payment_status, | |
| 237 | + | "one-time checkout not yet settled (async payment); deferring finalize until async_payment_succeeded" | |
| 238 | + | ); | |
| 239 | + | return Ok(()); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | if payments::is_tip_checkout(meta) { | |
| 243 | + | checkout::handle_tip_checkout_completed(state, session, event_id).await | |
| 244 | + | } else if payments::is_guest_checkout(meta) { | |
| 245 | + | checkout::handle_guest_checkout_completed(state, session, event_id).await | |
| 246 | + | } else if payments::is_cart_checkout(meta) { | |
| 247 | + | checkout::handle_cart_checkout_completed(state, session, event_id).await | |
| 248 | + | } else { | |
| 249 | + | checkout::handle_purchase_checkout_completed(state, session, event_id).await | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 201 | 253 | /// Handle account.updated from the v2 thin event endpoint. | |
| 202 | 254 | pub(in crate::routes::stripe) async fn handle_account_updated_from_v2( | |
| 203 | 255 | state: &AppState, |
| @@ -54,8 +54,20 @@ async fn post_webhook_json( | |||
| 54 | 54 | event_type: &str, | |
| 55 | 55 | object: serde_json::Value, | |
| 56 | 56 | ) -> crate::harness::client::TestResponse { | |
| 57 | + | post_webhook_json_with_event_id(h, "evt_mock_001", event_type, object).await | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Post a JSON webhook event with an explicit event id. Distinct ids are | |
| 61 | + | /// required when a single test delivers more than one event, since the webhook | |
| 62 | + | /// dedup short-circuits a repeated id. | |
| 63 | + | async fn post_webhook_json_with_event_id( | |
| 64 | + | h: &mut TestHarness, | |
| 65 | + | event_id: &str, | |
| 66 | + | event_type: &str, | |
| 67 | + | object: serde_json::Value, | |
| 68 | + | ) -> crate::harness::client::TestResponse { | |
| 57 | 69 | let payload = serde_json::json!({ | |
| 58 | - | "id": "evt_mock_001", | |
| 70 | + | "id": event_id, | |
| 59 | 71 | "type": event_type, | |
| 60 | 72 | "data": {"object": object}, | |
| 61 | 73 | }) | |
| @@ -153,6 +165,99 @@ async fn checkout_creates_session_and_webhook_completes_purchase() { | |||
| 153 | 165 | assert_eq!(sales, 1); | |
| 154 | 166 | } | |
| 155 | 167 | ||
| 168 | + | /// An asynchronous payment method (ACH/SEPA/Bacs) delivers | |
| 169 | + | /// `checkout.session.completed` with `payment_status="unpaid"` BEFORE funds | |
| 170 | + | /// settle. The one-time purchase must NOT finalize then — goods must not ship | |
| 171 | + | /// against unpaid funds — and must finalize when Stripe later re-delivers the | |
| 172 | + | /// settled session via `checkout.session.async_payment_succeeded`. | |
| 173 | + | #[tokio::test] | |
| 174 | + | async fn async_unpaid_checkout_defers_until_payment_succeeds() { | |
| 175 | + | let mut h = TestHarness::with_mocks().await; | |
| 176 | + | let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 777).await; | |
| 177 | + | ||
| 178 | + | let buyer_id = h.signup("asyncbuyer", "asyncbuyer@test.com", "pass1234").await; | |
| 179 | + | ||
| 180 | + | // Buyer initiates checkout, creating the pending transaction. | |
| 181 | + | h.client.post_form( | |
| 182 | + | &format!("/stripe/checkout/{}", item_id), | |
| 183 | + | "share_contact=false", | |
| 184 | + | ).await; | |
| 185 | + | ||
| 186 | + | let pending_tx: Option<(String,)> = sqlx::query_as( | |
| 187 | + | "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", | |
| 188 | + | ) | |
| 189 | + | .bind(buyer_id) | |
| 190 | + | .fetch_optional(&h.db) | |
| 191 | + | .await | |
| 192 | + | .unwrap(); | |
| 193 | + | let session_id = pending_tx.expect("checkout created a pending transaction").0; | |
| 194 | + | ||
| 195 | + | let mut meta = HashMap::new(); | |
| 196 | + | meta.insert("buyer_id".to_string(), buyer_id.to_string()); | |
| 197 | + | meta.insert("seller_id".to_string(), seller_id.to_string()); | |
| 198 | + | meta.insert("item_id".to_string(), item_id.clone()); | |
| 199 | + | ||
| 200 | + | // 1. completed + unpaid → deferred (still pending, no sale counted). | |
| 201 | + | let unpaid = serde_json::json!({ | |
| 202 | + | "id": session_id, | |
| 203 | + | "object": "checkout_session", | |
| 204 | + | "mode": "payment", | |
| 205 | + | "metadata": meta, | |
| 206 | + | "payment_intent": "pi_async_001", | |
| 207 | + | "payment_status": "unpaid", | |
| 208 | + | "currency": "usd", | |
| 209 | + | }); | |
| 210 | + | let resp = post_webhook_json_with_event_id(&mut h, "evt_async_unpaid", "checkout.session.completed", unpaid).await; | |
| 211 | + | assert_eq!(resp.status.as_u16(), 200, "webhook should ack even when deferring: {}", resp.text); | |
| 212 | + | ||
| 213 | + | let status: String = sqlx::query_scalar( | |
| 214 | + | "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", | |
| 215 | + | ) | |
| 216 | + | .bind(buyer_id) | |
| 217 | + | .bind(&item_id) | |
| 218 | + | .fetch_one(&h.db) | |
| 219 | + | .await | |
| 220 | + | .unwrap(); | |
| 221 | + | assert_eq!(status, "pending", "unpaid async session must NOT finalize the purchase"); | |
| 222 | + | ||
| 223 | + | let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") | |
| 224 | + | .bind(&item_id) | |
| 225 | + | .fetch_one(&h.db) | |
| 226 | + | .await | |
| 227 | + | .unwrap(); | |
| 228 | + | assert_eq!(sales, 0, "no sale should be counted before settlement"); | |
| 229 | + | ||
| 230 | + | // 2. async_payment_succeeded (funds settled) → finalize. | |
| 231 | + | let settled = serde_json::json!({ | |
| 232 | + | "id": session_id, | |
| 233 | + | "object": "checkout_session", | |
| 234 | + | "mode": "payment", | |
| 235 | + | "metadata": meta, | |
| 236 | + | "payment_intent": "pi_async_001", | |
| 237 | + | "payment_status": "paid", | |
| 238 | + | "currency": "usd", | |
| 239 | + | }); | |
| 240 | + | let resp = post_webhook_json_with_event_id(&mut h, "evt_async_paid", "checkout.session.async_payment_succeeded", settled).await; | |
| 241 | + | assert_eq!(resp.status.as_u16(), 200, "settlement webhook failed: {}", resp.text); | |
| 242 | + | ||
| 243 | + | let status: String = sqlx::query_scalar( | |
| 244 | + | "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", | |
| 245 | + | ) | |
| 246 | + | .bind(buyer_id) | |
| 247 | + | .bind(&item_id) | |
| 248 | + | .fetch_one(&h.db) | |
| 249 | + | .await | |
| 250 | + | .unwrap(); | |
| 251 | + | assert_eq!(status, "completed", "settled async session must finalize the purchase"); | |
| 252 | + | ||
| 253 | + | let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") | |
| 254 | + | .bind(&item_id) | |
| 255 | + | .fetch_one(&h.db) | |
| 256 | + | .await | |
| 257 | + | .unwrap(); | |
| 258 | + | assert_eq!(sales, 1, "sale should be counted once settled"); | |
| 259 | + | } | |
| 260 | + | ||
| 156 | 261 | // --------------------------------------------------------------------------- | |
| 157 | 262 | // Email assertions | |
| 158 | 263 | // --------------------------------------------------------------------------- |