//! Purchases made without an account. //! //! A guest purchase lands with `buyer_id` NULL, identified by its claim and //! download tokens, and is attached to an account out of band once the buyer //! verifies the email it was bought with. These are the only queries that //! touch `claim_token`, `download_token` or `guest_email`. use super::super::{ Cents, ClaimToken, DbTransaction, DownloadToken, ItemId, PgPool, ProjectId, PromoCodeId, Result, TransactionId, UserId, }; /// Complete a guest transaction: mark it completed, record the guest email, and /// mint a `claim_token` so the buyer can later attach the purchase to an account. /// /// Guest purchases always land unclaimed (`buyer_id` NULL); attachment to a user /// happens out of band via [`attach_guest_purchases_by_email`] at signup/email /// verification. #[tracing::instrument(skip_all)] pub async fn complete_guest_transaction<'e>( executor: impl sqlx::PgExecutor<'e>, stripe_checkout_session_id: &str, stripe_payment_intent_id: Option<&str>, guest_email: &str, ) -> Result> { let claim_token = ClaimToken::new(); let tx = sqlx::query_as!( DbTransaction, r#" UPDATE transactions SET status = 'completed', stripe_payment_intent_id = $2, completed_at = NOW(), guest_email = $3, claim_token = $4, buyer_id = NULL WHERE stripe_checkout_session_id = $1 AND status = 'pending' RETURNING id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, created_at AS "created_at: chrono::DateTime", completed_at AS "completed_at: chrono::DateTime", item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", download_token AS "download_token: DownloadToken", presentment_amount_cents, presentment_currency "#, stripe_checkout_session_id, stripe_payment_intent_id, guest_email, claim_token as ClaimToken, ) .fetch_optional(executor) .await?; Ok(tx) } /// Attach all unclaimed guest purchases for an email to a user account. /// Called during signup/email verification to auto-claim prior guest purchases. #[tracing::instrument(skip_all)] pub async fn attach_guest_purchases_by_email( pool: &PgPool, email: &str, user_id: UserId, ) -> Result { let result = sqlx::query!( r#" UPDATE transactions SET buyer_id = $1, claimed_by = $1, claim_token = NULL WHERE LOWER(guest_email) = LOWER($2) AND buyer_id IS NULL AND status = 'completed' "#, user_id as UserId, email, ) .execute(pool) .await?; Ok(result.rows_affected()) } /// Claim a single guest purchase by claim token. #[tracing::instrument(skip_all)] pub async fn claim_guest_purchase( pool: &PgPool, claim_token: ClaimToken, user_id: UserId, ) -> Result> { let tx = sqlx::query_as!( DbTransaction, r#" UPDATE transactions SET buyer_id = $2, claimed_by = $2, claim_token = NULL WHERE claim_token = $1 AND buyer_id IS NULL AND status = 'completed' RETURNING id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, created_at AS "created_at: chrono::DateTime", completed_at AS "completed_at: chrono::DateTime", item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", download_token AS "download_token: DownloadToken", presentment_amount_cents, presentment_currency "#, claim_token as ClaimToken, user_id as UserId, ) .fetch_optional(pool) .await?; Ok(tx) } /// Look up a completed transaction by download token (for guest download links). #[tracing::instrument(skip_all)] pub async fn get_transaction_by_download_token( pool: &PgPool, download_token: DownloadToken, ) -> Result> { let tx = sqlx::query_as!( DbTransaction, r#" SELECT id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, created_at AS "created_at: chrono::DateTime", completed_at AS "completed_at: chrono::DateTime", item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", download_token AS "download_token: DownloadToken", presentment_amount_cents, presentment_currency FROM transactions WHERE download_token = $1 AND status = 'completed' "#, download_token as DownloadToken, ) .fetch_optional(pool) .await?; Ok(tx) } /// Create a completed free guest transaction. /// /// Returns the number of rows inserted (0 if already claimed via ON CONFLICT). #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all)] pub async fn create_free_guest_transaction( pool: &PgPool, buyer_id: Option, seller_id: UserId, item_id: ItemId, checkout_session_id: &str, item_title: &str, seller_username: &str, guest_email: &str, claim_token: Option, download_token: DownloadToken, ) -> std::result::Result { let result = sqlx::query!( r#" INSERT INTO transactions ( buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, guest_email, claim_token, download_token ) VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, false, $7, $8, $9) ON CONFLICT (guest_email, item_id) WHERE status = 'completed' AND guest_email IS NOT NULL DO NOTHING "#, buyer_id as Option, seller_id as UserId, item_id as ItemId, checkout_session_id, item_title, seller_username, guest_email, claim_token as Option, download_token as DownloadToken, ) .execute(pool) .await?; Ok(result.rows_affected()) }