| 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 |
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();
|