Skip to main content

max / makenotwork

Store NULL, not "unknown", for PI-less transaction completions The four checkout-completion handlers wrote a literal "unknown" into the money-keyed stripe_payment_intent_id column when a session had no payment intent. That column is the lookup key for refund_transaction_by_payment_intent, so two PI-less rows writing "unknown" could collide and let a charge.refunded match the wrong order. complete_transaction / complete_cart_transactions / complete_tip / complete_guest_transaction now take Option<&str> and write NULL, which can't collide in a WHERE = $1 lookup (a PI-less row has no PI to refund by). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 04:58 UTC
Commit: 4ab32de0b3477d30a757051ed8e725f6f3fb15b7
Parent: 9206c0d
4 files changed, +25 insertions, -17 deletions
@@ -56,7 +56,7 @@ pub async fn create_tip(
56 56 pub async fn complete_tip<'e>(
57 57 executor: impl sqlx::PgExecutor<'e>,
58 58 stripe_checkout_session_id: &str,
59 - stripe_payment_intent_id: &str,
59 + stripe_payment_intent_id: Option<&str>,
60 60 ) -> Result<Option<DbTip>> {
61 61 let tip = sqlx::query_as!(
62 62 DbTip,
@@ -91,7 +91,7 @@ pub async fn create_transaction<'e>(
91 91 pub async fn complete_guest_transaction<'e>(
92 92 executor: impl sqlx::PgExecutor<'e>,
93 93 stripe_checkout_session_id: &str,
94 - stripe_payment_intent_id: &str,
94 + stripe_payment_intent_id: Option<&str>,
95 95 guest_email: &str,
96 96 ) -> Result<Option<DbTransaction>> {
97 97 let claim_token = ClaimToken::new();
@@ -224,7 +224,7 @@ pub async fn get_transaction_by_download_token(
224 224 pub async fn complete_transaction<'e>(
225 225 executor: impl sqlx::PgExecutor<'e>,
226 226 stripe_checkout_session_id: &str,
227 - stripe_payment_intent_id: &str,
227 + stripe_payment_intent_id: Option<&str>,
228 228 ) -> Result<Option<DbTransaction>> {
229 229 // Only update if status is 'pending' for idempotency
230 230 // Returns None if transaction was already completed (duplicate webhook)
@@ -262,7 +262,7 @@ pub async fn complete_transaction<'e>(
262 262 pub async fn complete_cart_transactions<'e>(
263 263 executor: impl sqlx::PgExecutor<'e>,
264 264 stripe_checkout_session_id: &str,
265 - stripe_payment_intent_id: &str,
265 + stripe_payment_intent_id: Option<&str>,
266 266 ) -> Result<Vec<DbTransaction>> {
267 267 let txs = sqlx::query_as!(
268 268 DbTransaction,
@@ -73,14 +73,17 @@ pub(super) async fn handle_purchase_checkout_completed(
73 73 let item_id_display = item_id.map(|id| id.to_string()).unwrap_or_else(|| "project".to_string());
74 74
75 75 // Get the payment intent ID
76 - let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
76 + // Display/logging copy only; the DB write below passes `session.payment_intent`
77 + // directly so a PI-less session stores NULL, not a literal "unknown" that would
78 + // collide with other PI-less rows in the money-keyed lookup column (Run 9).
79 + let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
77 80
78 81 // Complete the transaction (idempotent - returns None if already completed).
79 82 // Steps 1-3 (complete_transaction, increment_sales_count, discount code increment)
80 83 // are wrapped in a single DB transaction to prevent inconsistent state if any step fails.
81 84 let mut db_tx = state.db.begin().await.context("begin purchase webhook transaction")?;
82 85
83 - match db::transactions::complete_transaction(&mut *db_tx, &session_id, &payment_intent_id).await {
86 + match db::transactions::complete_transaction(&mut *db_tx, &session_id, session.payment_intent.as_deref()).await {
84 87 Ok(Some(tx)) => {
85 88 tracing::info!(
86 89 buyer_id = %buyer_id, seller_id = %seller_id, item_id = %item_id_display, amount_cents = %tx.amount_cents,
@@ -191,13 +194,16 @@ pub(super) async fn handle_cart_checkout_completed(
191 194 let buyer_id = meta.buyer_id;
192 195 let seller_id = meta.seller_id;
193 196
194 - let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
197 + // Display/logging copy only; the DB write below passes `session.payment_intent`
198 + // directly so a PI-less session stores NULL, not a literal "unknown" that would
199 + // collide with other PI-less rows in the money-keyed lookup column (Run 9).
200 + let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
195 201
196 202 // Complete ALL pending transactions for this session in a single DB transaction
197 203 let mut db_tx = state.db.begin().await.context("begin cart webhook transaction")?;
198 204
199 205 let completed_txs = db::transactions::complete_cart_transactions(
200 - &mut *db_tx, &session_id, &payment_intent_id,
206 + &mut *db_tx, &session_id, session.payment_intent.as_deref(),
201 207 )
202 208 .await
203 209 .context("complete cart transactions")?;
@@ -588,10 +594,9 @@ pub(super) async fn handle_tip_checkout_completed(
588 594 let tipper_id = metadata.tipper_id;
589 595 let recipient_id = metadata.recipient_id;
590 596
591 - let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
592 -
593 - // Complete the tip (idempotent)
594 - match db::tips::complete_tip(&state.db, &session_id, &payment_intent_id)
597 + // Complete the tip (idempotent). A PI-less session stores NULL (not a literal
598 + // "unknown") in the money-keyed lookup column (Run 9).
599 + match db::tips::complete_tip(&state.db, &session_id, session.payment_intent.as_deref())
595 600 .await
596 601 .context("complete tip")? {
597 602 Some(tip) => {
@@ -649,7 +654,10 @@ pub(super) async fn handle_guest_checkout_completed(
649 654 .unwrap_or("unknown@guest")
650 655 .to_string();
651 656
652 - let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
657 + // Display/logging copy only; the DB write below passes `session.payment_intent`
658 + // directly so a PI-less session stores NULL, not a literal "unknown" that would
659 + // collide with other PI-less rows in the money-keyed lookup column (Run 9).
660 + let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
653 661
654 662 // Complete the guest transaction and increment sales count in a single DB transaction
655 663 // (matching the non-guest path pattern to prevent counter drift on partial failure)
@@ -664,7 +672,7 @@ pub(super) async fn handle_guest_checkout_completed(
664 672 match db::transactions::complete_guest_transaction(
665 673 &mut *db_tx,
666 674 &session_id,
667 - &payment_intent_id,
675 + session.payment_intent.as_deref(),
668 676 &guest_email,
669 677 ).await? {
670 678 Some(tx) => {
@@ -182,13 +182,13 @@ async fn complete_tip_is_idempotent() {
182 182 .unwrap();
183 183
184 184 // First completion transitions pending → completed and returns the row.
185 - let first = db::tips::complete_tip(&h.db, session, "pi_tip_complete_001")
185 + let first = db::tips::complete_tip(&h.db, session, Some("pi_tip_complete_001"))
186 186 .await
187 187 .unwrap();
188 188 assert!(first.is_some(), "first completion must update the pending tip");
189 189
190 190 // A redelivered webhook completing the same session is a no-op.
191 - let second = db::tips::complete_tip(&h.db, session, "pi_tip_complete_001")
191 + let second = db::tips::complete_tip(&h.db, session, Some("pi_tip_complete_001"))
192 192 .await
193 193 .unwrap();
194 194 assert!(second.is_none(), "completing an already-completed tip must be idempotent");
@@ -208,7 +208,7 @@ async fn refund_tip_is_idempotent_and_scoped() {
208 208 db::tips::create_tip(&h.db, tipper, recipient, None, 800, None, session)
209 209 .await
210 210 .unwrap();
211 - db::tips::complete_tip(&h.db, session, pi).await.unwrap();
211 + db::tips::complete_tip(&h.db, session, Some(pi)).await.unwrap();
212 212
213 213 // First refund flips completed → refunded.
214 214 assert!(db::tips::refund_tip_by_payment_intent(&h.db, pi).await.unwrap());