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