Skip to main content

max / makenotwork

Require a claim link for guest purchases (no email auto-attach) A paid guest checkout no longer auto-attaches to an existing account when the Stripe-collected email matches a verified user: Stripe doesn't prove the guest controls that address, so matching alone let someone drop a purchase into a stranger's library. The buyer always claims via the emailed claim link, which authenticates the recipient. Mint the license key at claim time instead of at auto-attach. This also closes a pre-existing gap: a pure-guest buyer of a key-enabled item who claimed (rather than matching by email) never received a key, since claim_purchase didn't generate one. maybe_generate_license_key is now pub(crate) and reused; claim_guest_purchase's buyer_id-IS-NULL guard keeps it single-use. Note: the free-guest path (claim_free_guest) still auto-attaches on email match; left for a separate UX call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:31 UTC
Commit: f0f01c9b5f00aee9247cd14171bf3ba9ceaed77d
Parent: a04dbf2
5 files changed, +28 insertions, -26 deletions
@@ -296,6 +296,19 @@ pub(super) async fn claim_purchase(
296 296 "guest purchase claimed"
297 297 );
298 298
299 + // Issue the license key at claim time. Guest purchases of key-enabled items
300 + // are no longer auto-attached on an email match (the buyer always claims via
301 + // the emailed link), so the key must be minted here — the claim is the point
302 + // at which a real, authenticated buyer is known. `claim_guest_purchase`'s
303 + // `buyer_id IS NULL` guard makes the claim single-use, so this runs at most
304 + // once per purchase.
305 + if let Some(item_id) = tx.item_id {
306 + crate::routes::stripe::webhook::checkout_helpers::maybe_generate_license_key(
307 + &state, item_id, user.id, tx.id,
308 + )
309 + .await;
310 + }
311 +
299 312 Ok(StatusCode::OK.into_response())
300 313 }
301 314
@@ -4,7 +4,7 @@
4 4
5 5 mod checkout;
6 6 mod connect;
7 - mod webhook;
7 + pub(crate) mod webhook;
8 8 mod webhook_v2;
9 9
10 10 pub(crate) use checkout::grant_bundle_items;
@@ -620,31 +620,24 @@ pub(super) async fn handle_guest_checkout_completed(
620 620 // (matching the non-guest path pattern to prevent counter drift on partial failure)
621 621 let mut db_tx = state.db.begin().await.context("begin guest checkout webhook transaction")?;
622 622
623 - // Check if email matches an existing user — auto-attach if so.
624 - // `FOR SHARE` blocks a concurrent email-change UPDATE from racing the
625 - // attach: if someone edits this user's email mid-checkout, the writer
626 - // waits for our tx to commit so we either attach the row we matched or
627 - // the writer wins and we see no row (treated as guest purchase).
628 - let existing_user_id: Option<db::UserId> = sqlx::query_scalar(
629 - "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND email_verified = true FOR SHARE",
630 - )
631 - .bind(&guest_email)
632 - .fetch_optional(&mut *db_tx)
633 - .await?;
634 -
623 + // Do NOT auto-attach on an email match. Stripe collects the buyer's email
624 + // but does not prove the guest controls it, so matching alone would let
625 + // someone drop a purchase into a stranger's verified library. The buyer
626 + // always claims via the emailed claim link, which authenticates the
627 + // recipient; the license key (if any) is minted at claim time
628 + // (Run #21 Payments MINOR-1 / Max's call 2026-06-15).
635 629 match db::transactions::complete_guest_transaction(
636 630 &mut *db_tx,
637 631 &session_id,
638 632 &payment_intent_id,
639 633 &guest_email,
640 - existing_user_id,
634 + None,
641 635 ).await? {
642 636 Some(tx) => {
643 637 tracing::info!(
644 638 session_id = %session_id,
645 639 guest_email = %guest_email,
646 640 item_id = %meta.item_id,
647 - auto_attached = tx.buyer_id.is_some(),
648 641 "guest transaction completed"
649 642 );
650 643
@@ -682,17 +675,13 @@ pub(super) async fn handle_guest_checkout_completed(
682 675
683 676 // --- Secondary effects below (outside transaction) ---
684 677
685 - // Generate license key if applicable and buyer was auto-attached
686 - if let Some(buyer_id) = tx.buyer_id {
687 - maybe_generate_license_key(state, meta.item_id, buyer_id, tx.id).await;
688 - }
689 -
690 - // Record revenue splits
678 + // A guest purchase is never auto-attached, so there's no buyer yet:
679 + // the license key (if any) is minted when the buyer claims, in
680 + // `claim_purchase`. Revenue splits are recorded now regardless.
691 681 record_transaction_splits(state, tx.id, meta.item_id, tx.amount_cents).await;
692 682
693 - // Send guest purchase confirmation email (only if not auto-attached to existing account)
694 - if tx.buyer_id.is_none()
695 - && let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token)
683 + // Always send the guest purchase confirmation with the claim link.
684 + if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token)
696 685 {
697 686 let email_client = state.email.clone();
698 687 let host_url = state.config.host_url.clone();
@@ -8,7 +8,7 @@ use crate::{
8 8 };
9 9
10 10 /// Generate a license key for the purchased item if keys are enabled.
11 - pub(super) async fn maybe_generate_license_key(
11 + pub(crate) async fn maybe_generate_license_key(
12 12 state: &AppState,
13 13 item_id: db::ItemId,
14 14 buyer_id: db::UserId,
@@ -2,7 +2,7 @@
2 2
3 3 mod billing;
4 4 mod checkout;
5 - mod checkout_helpers;
5 + pub(crate) mod checkout_helpers;
6 6 mod subscriptions;
7 7
8 8 use axum::{