Skip to main content

max / makenotwork

Make transaction item_id optional for project purchases Project-level purchases previously used ItemId::nil() as a sentinel, which would collide on the (buyer_id, item_id) unique index if the same buyer purchased two different projects. Now item_id is Option<ItemId> in CreateTransactionParams, CheckoutParams, and CheckoutMetadata. Project purchases pass None, and the unique indexes are filtered with AND item_id IS NOT NULL (migration 104) so NULLs don't collide. Project purchases are deduplicated by the separate buyer_project indexes instead. The webhook handler guards item-specific operations (increment_sales_count, license key generation, bundle granting, mailing list subscription) behind if let Some(item_id) checks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-09 03:20 UTC
Commit: ba3bbac4accd51caf1ed4eef991607ab131564a4
Parent: a81254d
9 files changed, +52 insertions, -30 deletions
@@ -62,7 +62,7 @@ Two-pass fuzz: initial scan + deep verification. Items marked REFUTED were dispr
62 62 - [x] MINOR: `CartLineItem.amount_cents` is `i32` while `Cents` wraps `i64` — changed to `i64`, removed redundant cast (`payments/checkout.rs`)
63 63 - [x] MINOR: No Stripe minimum amount ($0.50) enforcement before creating session — added `STRIPE_MINIMUM_CHARGE_CENTS` check to all 3 checkout methods (`payments/checkout.rs`, `constants.rs`)
64 64 - [ ] MINOR: Cart items removed before Stripe session completes — cancel = empty cart, intentional UX trade-off (`routes/stripe/checkout/cart.rs:356,673`)
65 - - [ ] MINOR: Project checkout uses `ItemId::nil()` — needs schema change to support per-project unique constraint (`routes/stripe/checkout/project.rs:135,148`)
65 + - [x] MINOR: Project checkout uses `ItemId::nil()` — made item_id optional in CreateTransactionParams, CheckoutParams, and CheckoutMetadata; project purchases now use NULL; fixed unique indexes to exclude NULL item_id (migration 104)
66 66 - [ ] MINOR: v2 webhook handler swallows account update failures — needs v2 retry queue infrastructure (`routes/stripe/webhook_v2.rs:95-106`)
67 67 - ~~SERIOUS: Partial Stripe refunds revoke full access~~ REFUTED — `is_full_refund()` check at `billing.rs:272` prevents this
68 68 - ~~MINOR: $0 items dropped in `process_seller_checkout`~~ REFUTED — unreachable; `process_seller_checkout` is only called with `promo_code: None`
@@ -0,0 +1,13 @@
1 + -- Fix unique indexes on transactions to exclude NULL item_id (project purchases).
2 + -- Previously, two project purchases by the same buyer would collide on the
3 + -- (buyer_id, item_id) index when item_id was set to a nil UUID sentinel.
4 + -- Now item_id is NULL for project purchases, and NULLs don't participate in
5 + -- unique indexes, so the project-level indexes handle deduplication instead.
6 +
7 + DROP INDEX IF EXISTS idx_transactions_buyer_item_completed;
8 + CREATE UNIQUE INDEX idx_transactions_buyer_item_completed
9 + ON transactions(buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL;
10 +
11 + DROP INDEX IF EXISTS idx_transactions_buyer_item_pending;
12 + CREATE UNIQUE INDEX idx_transactions_buyer_item_pending
13 + ON transactions(buyer_id, item_id) WHERE status = 'pending' AND item_id IS NOT NULL;
@@ -12,7 +12,8 @@ use crate::error::Result;
12 12 pub struct CreateTransactionParams<'a> {
13 13 pub buyer_id: Option<UserId>,
14 14 pub seller_id: UserId,
15 - pub item_id: ItemId,
15 + /// `None` for project-level purchases (no specific item).
16 + pub item_id: Option<ItemId>,
16 17 pub amount_cents: Cents,
17 18 pub platform_fee_cents: Cents,
18 19 pub stripe_checkout_session_id: &'a str,
@@ -18,7 +18,8 @@ pub struct CheckoutParams<'a> {
18 18 pub amount_cents: Cents,
19 19 pub buyer_id: UserId,
20 20 pub seller_id: UserId,
21 - pub item_id: ItemId,
21 + /// `None` for project-level purchases (no specific item).
22 + pub item_id: Option<ItemId>,
22 23 pub success_url: &'a str,
23 24 pub cancel_url: &'a str,
24 25 pub promo_code_id: Option<PromoCodeId>,
@@ -197,7 +198,9 @@ impl StripeClient {
197 198 let mut metadata = std::collections::HashMap::new();
198 199 metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
199 200 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
200 - metadata.insert("item_id".to_string(), checkout.item_id.to_string());
201 + if let Some(item_id) = checkout.item_id {
202 + metadata.insert("item_id".to_string(), item_id.to_string());
203 + }
201 204 if let Some(pc_id) = checkout.promo_code_id {
202 205 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
203 206 }
@@ -590,8 +593,8 @@ pub struct CheckoutMetadata {
590 593 pub buyer_id: UserId,
591 594 /// UUID of the creator receiving payment.
592 595 pub seller_id: UserId,
593 - /// UUID of the item being purchased.
594 - pub item_id: ItemId,
596 + /// UUID of the item being purchased (`None` for project-level purchases).
597 + pub item_id: Option<ItemId>,
595 598 /// UUID of the promo code used, if any.
596 599 pub promo_code_id: Option<PromoCodeId>,
597 600 }
@@ -614,11 +617,8 @@ impl CheckoutMetadata {
614 617 .map(UserId::from)
615 618 .map_err(|_| AppError::BadRequest("Invalid seller_id format".to_string()))?;
616 619
617 - let item_id: ItemId = metadata.get("item_id")
618 - .ok_or_else(|| AppError::BadRequest("Missing item_id in metadata".to_string()))?
619 - .parse::<uuid::Uuid>()
620 - .map(ItemId::from)
621 - .map_err(|_| AppError::BadRequest("Invalid item_id format".to_string()))?;
620 + let item_id: Option<ItemId> = metadata.get("item_id")
621 + .and_then(|v| v.parse::<uuid::Uuid>().ok().map(ItemId::from));
622 622
623 623 let promo_code_id: Option<PromoCodeId> = metadata.get("promo_code_id")
624 624 .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
@@ -937,7 +937,7 @@ mod tests {
937 937 let result = CheckoutMetadata::from_session(&session).unwrap();
938 938 assert_eq!(result.buyer_id, buyer);
939 939 assert_eq!(result.seller_id, seller);
940 - assert_eq!(result.item_id, item);
940 + assert_eq!(result.item_id, Some(item));
941 941 }
942 942
943 943 #[test]
@@ -182,7 +182,7 @@ pub(super) async fn create_guest_checkout(
182 182 &db::transactions::CreateTransactionParams {
183 183 buyer_id: None,
184 184 seller_id,
185 - item_id,
185 + item_id: Some(item_id),
186 186 amount_cents: final_price_cents.into(),
187 187 platform_fee_cents: Cents::ZERO,
188 188 stripe_checkout_session_id: &result.id,
@@ -324,7 +324,7 @@ pub(in crate::routes::stripe) async fn create_cart_checkout(
324 324 &db::transactions::CreateTransactionParams {
325 325 buyer_id: Some(user.id),
326 326 seller_id,
327 - item_id: item.item_id,
327 + item_id: Some(item.item_id),
328 328 amount_cents: Cents::new(*final_price as i64),
329 329 platform_fee_cents: Cents::ZERO,
330 330 stripe_checkout_session_id: &result.id,
@@ -650,7 +650,7 @@ pub(super) async fn process_seller_checkout(
650 650 &db::transactions::CreateTransactionParams {
651 651 buyer_id: Some(user.id),
652 652 seller_id,
653 - item_id: item.item_id,
653 + item_id: Some(item.item_id),
654 654 amount_cents: Cents::new(*final_price as i64),
655 655 platform_fee_cents: Cents::ZERO,
656 656 stripe_checkout_session_id: &result.id,
@@ -319,7 +319,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
319 319 amount_cents: Cents::new(final_price_cents as i64),
320 320 buyer_id: user.id,
321 321 seller_id,
322 - item_id: item_uuid,
322 + item_id: Some(item_uuid),
323 323 success_url: &success_url,
324 324 cancel_url: &cancel_url,
325 325 promo_code_id,
@@ -344,7 +344,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
344 344 &db::transactions::CreateTransactionParams {
345 345 buyer_id: Some(user.id),
346 346 seller_id,
347 - item_id: item_uuid,
347 + item_id: Some(item_uuid),
348 348 amount_cents: final_price_cents.into(),
349 349 platform_fee_cents: Cents::ZERO, // 0% platform fee
350 350 stripe_checkout_session_id: &session.id,
@@ -132,7 +132,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
132 132 amount_cents: Cents::new(base_price_cents as i64),
133 133 buyer_id: user.id,
134 134 seller_id,
135 - item_id: db::ItemId::nil(), // no item for project purchases
135 + item_id: None, // project-level purchase, no specific item
136 136 success_url: &success_url,
137 137 cancel_url: &cancel_url,
138 138 promo_code_id: None,
@@ -145,7 +145,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
145 145 &db::transactions::CreateTransactionParams {
146 146 buyer_id: Some(user.id),
147 147 seller_id,
148 - item_id: db::ItemId::nil(),
148 + item_id: None,
149 149 amount_cents: base_price_cents.into(),
150 150 platform_fee_cents: Cents::ZERO,
151 151 stripe_checkout_session_id: &session.id,
@@ -32,6 +32,8 @@ pub(super) async fn handle_purchase_checkout_completed(
32 32 let item_id = raw_metadata.item_id;
33 33 let _promo_code_id = raw_metadata.promo_code_id;
34 34
35 + let item_id_display = item_id.map(|id| id.to_string()).unwrap_or_else(|| "project".to_string());
36 +
35 37 // Get the payment intent ID
36 38 let payment_intent_id = session.payment_intent
37 39 .as_ref()
@@ -46,14 +48,16 @@ pub(super) async fn handle_purchase_checkout_completed(
46 48 match db::transactions::complete_transaction(&mut *db_tx, &session_id, &payment_intent_id).await {
47 49 Ok(Some(tx)) => {
48 50 tracing::info!(
49 - buyer_id = %buyer_id, seller_id = %seller_id, item_id = %item_id, amount_cents = %tx.amount_cents,
51 + buyer_id = %buyer_id, seller_id = %seller_id, item_id = %item_id_display, amount_cents = %tx.amount_cents,
50 52 "transaction completed"
51 53 );
52 54
53 55 // Increment denormalized sales_count (inside transaction)
54 - db::items::increment_sales_count(&mut *db_tx, item_id)
55 - .await
56 - .with_context(|| format!("increment sales count for item {item_id}"))?;
56 + if let Some(iid) = item_id {
57 + db::items::increment_sales_count(&mut *db_tx, iid)
58 + .await
59 + .with_context(|| format!("increment sales count for item {iid}"))?;
60 + }
57 61
58 62 // Promo code use_count is reserved at checkout time (not here) to prevent
59 63 // concurrent checkouts from exceeding max_uses. No increment needed in webhook.
@@ -64,10 +68,12 @@ pub(super) async fn handle_purchase_checkout_completed(
64 68 // --- Secondary effects below (outside transaction) ---
65 69
66 70 // Grant access to bundle child items (if this is a bundle)
67 - if let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await
68 - && purchased_item.item_type == db::ItemType::Bundle
69 - {
70 - crate::routes::stripe::checkout::grant_bundle_items(state, item_id, buyer_id, seller_id, Some(tx.id)).await;
71 + if let Some(iid) = item_id {
72 + if let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, iid).await
73 + && purchased_item.item_type == db::ItemType::Bundle
74 + {
75 + crate::routes::stripe::checkout::grant_bundle_items(state, iid, buyer_id, seller_id, Some(tx.id)).await;
76 + }
71 77 }
72 78
73 79 if tx.share_contact {
@@ -77,11 +83,13 @@ pub(super) async fn handle_purchase_checkout_completed(
77 83 }
78 84
79 85 // Record revenue splits if the item's project has members
80 - record_transaction_splits(state, tx.id, item_id, tx.amount_cents).await;
86 + if let Some(iid) = item_id {
87 + record_transaction_splits(state, tx.id, iid, tx.amount_cents).await;
88 + maybe_generate_license_key(state, iid, buyer_id, tx.id).await;
89 + subscribe_buyer_to_mailing_list(state, iid, buyer_id);
90 + }
81 91
82 - maybe_generate_license_key(state, item_id, buyer_id, tx.id).await;
83 92 send_purchase_emails(state, &tx, buyer_id, seller_id);
84 - subscribe_buyer_to_mailing_list(state, item_id, buyer_id);
85 93
86 94 if let Err(e) = db::subscriptions::log_subscription_event(
87 95 &state.db, None, event_id, "checkout.session.completed.purchase",