//! Taking something that costs nothing. //! //! Every function here writes `amount_cents = 0, status = 'completed'` through //! the same `ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND //! item_id IS NOT NULL DO NOTHING` idempotency clause, and three carry //! `platform_credit_cents` for the Fan+ reimbursement. use super::super::{ ItemId, KeyCode, PgPool, ProjectId, PromoCodeId, Result, TransactionId, UserId, }; /// Common parameters for claiming a free item (direct, discount code, or download code). pub struct ClaimParams<'a> { pub buyer_id: UserId, pub item_id: ItemId, pub seller_id: UserId, pub item_title: &'a str, pub seller_username: &'a str, pub share_contact: bool, /// If this claim was granted via a bundle purchase, the parent transaction ID. pub parent_transaction_id: Option, /// Cents MNW owes the seller when a platform-wide (Fan+) credit made this item /// free, the seller is reimbursed the item's price via a platform transfer so /// they are still paid. `0` for ordinary free claims (genuinely-free items, /// seller-issued free-access codes, bundle grants). pub platform_credit_cents: i64, } /// Claims a free item by creating a zero-cost completed transaction. /// Returns true if claimed successfully, false if already in library. /// /// Uses `ON CONFLICT DO NOTHING` against the partial unique index on /// `(buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL` to prevent duplicate /// claims under concurrent requests. #[tracing::instrument(skip_all)] pub async fn claim_free_item<'e>( executor: impl sqlx::PgExecutor<'e>, params: &ClaimParams<'_>, ) -> Result { let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id); 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, parent_transaction_id, platform_credit_cents) VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9) ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING "#, params.buyer_id as UserId, params.seller_id as UserId, params.item_id as ItemId, claim_id, params.item_title, params.seller_username, params.share_contact, params.parent_transaction_id as Option, params.platform_credit_cents, ) .execute(executor) .await?; Ok(result.rows_affected() > 0) } /// Batch variant of [`claim_free_item`] for bundle grants: claims every child /// item for one buyer in a single INSERT instead of N round-trips (each with its /// own pool acquire) on the Stripe webhook / checkout hot path. /// Each row is idempotent via the same partial-unique ON CONFLICT as the single /// claim, and child items deliberately do not increment `sales_count`. Returns the /// number of rows actually inserted. No-op on an empty slice. #[tracing::instrument(skip_all)] pub async fn claim_free_items_batch<'e>( executor: impl sqlx::PgExecutor<'e>, buyer_id: UserId, seller_id: UserId, seller_username: &str, parent_transaction_id: Option, items: &[(ItemId, &str)], ) -> Result { if items.is_empty() { return Ok(0); } let item_ids: Vec = items.iter().map(|(id, _)| *id).collect(); let item_titles: Vec<&str> = items.iter().map(|(_, title)| *title).collect(); // The per-row claim id mirrors the single claim's `free-claim-{buyer}-{item}` // so a later single claim of the same item still collides idempotently. 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, parent_transaction_id) SELECT $1, $2, t.item_id, 0, 0, 'free-claim-' || $1::text || '-' || t.item_id::text, 'completed', NOW(), t.item_title, $3, false, $4 FROM UNNEST($5::uuid[], $6::text[]) AS t(item_id, item_title) ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING ", ) .bind(buyer_id) .bind(seller_id) .bind(seller_username) .bind(parent_transaction_id) .bind(&item_ids) .bind(&item_titles) .execute(executor) .await?; Ok(result.rows_affected()) } /// Optional parameters for generating a license key inside a claim transaction. pub struct LicenseKeyParams<'a> { pub key_code: &'a KeyCode, pub max_activations: Option, } /// Atomically claim a free item and increment the promo code's use count. /// /// Claims the item FIRST (INSERT transaction), then increments use_count. /// If the user already owns the item (rows_affected == 0), rolls back without /// consuming the code. If the code limit is reached, rolls back the claim too. /// /// When `license_key_params` is `Some`, a license key is created inside the /// same transaction so that the claim and key are always consistent. /// /// Returns `(code_accepted, item_claimed)`: /// - `code_accepted = false` → promo code hit its usage limit (nothing changed) /// - `item_claimed = false` → user already owns the item (code was NOT consumed) #[tracing::instrument(skip_all)] pub async fn claim_free_with_promo_code( pool: &PgPool, promo_code_id: PromoCodeId, params: &ClaimParams<'_>, license_key_params: Option<&LicenseKeyParams<'_>>, ) -> Result<(bool, bool)> { let mut tx = pool.begin().await?; // Claim the item first. When a platform-wide credit (Fan+) // made the item free, `platform_credit_cents` carries the item's full price so // the scheduler reimburses the creator via transfer, the fan pays nothing but // the creator is still paid (MNW funds it). let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id); 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, promo_code_id, platform_credit_cents) VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9) ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING "#, params.buyer_id as UserId, params.seller_id as UserId, params.item_id as ItemId, claim_id, params.item_title, params.seller_username, params.share_contact, promo_code_id as PromoCodeId, params.platform_credit_cents, ) .execute(&mut *tx) .await?; let claimed = result.rows_affected() > 0; if !claimed { tx.rollback().await?; return Ok((true, false)); } // Increment the promo code use count. Re-check the full validity // window (max_uses AND starts_at/expires_at) inside the atomic UPDATE, the // pre-flight check ran outside this transaction, so a code expiring in the // sub-ms gap must still be rejected here, not just on use-count (Run 11 Pay // MINOR / TOCTOU). let code_result = sqlx::query!( r#" UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses) AND (starts_at IS NULL OR starts_at <= NOW()) AND (expires_at IS NULL OR expires_at > NOW()) "#, promo_code_id as PromoCodeId, ) .execute(&mut *tx) .await?; if code_result.rows_affected() == 0 { tx.rollback().await?; return Ok((false, false)); } crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?; // Create the license key inside the same transaction if requested. // Retry once on a unique-violation: the wordlist generator has ~6B-coin- // flip headroom, so an actual collision is vanishingly rare, but the // alternative is surfacing a 500 to a buyer mid-claim, cheap to handle. if let Some(lk) = license_key_params { let attempt = sqlx::query!( r#" INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations) VALUES ($1, $2, NULL, $3, $4) "#, params.item_id as ItemId, params.buyer_id as UserId, lk.key_code as &KeyCode, lk.max_activations, ) .execute(&mut *tx) .await; if let Err(sqlx::Error::Database(e)) = &attempt && e.code().as_deref() == Some("23505") { let retry_code = crate::helpers::generate_key_code(); tracing::warn!(item_id = %params.item_id, "license key 23505 collision; retrying once"); sqlx::query!( r#" INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations) VALUES ($1, $2, NULL, $3, $4) "#, params.item_id as ItemId, params.buyer_id as UserId, retry_code as KeyCode, lk.max_activations, ) .execute(&mut *tx) .await?; } else { attempt?; } } tx.commit().await?; Ok((true, true)) } /// Remove a free item from library (deletes the claim transaction). /// If the claim was via a promo code, decrements the code's use_count. #[tracing::instrument(skip_all)] pub async fn remove_free_item_from_library( pool: &PgPool, user_id: UserId, item_id: ItemId, ) -> Result { // Delete the free claim and return the promo_code_id if one was used let row: Option> = sqlx::query_scalar!( r#" DELETE FROM transactions WHERE buyer_id = $1 AND item_id = $2 AND amount_cents = 0 AND status = 'completed' RETURNING promo_code_id AS "promo_code_id: crate::db::PromoCodeId" "#, user_id as UserId, item_id as ItemId, ) .fetch_optional(pool) .await?; let deleted = row.is_some(); if let Some(Some(pc_id)) = row { crate::db::promo_codes::release_use_count(pool, pc_id) .await .ok(); } Ok(deleted) } /// Record a free project claim (PWYW with $0 min or free project). /// /// Returns `true` if the claim was actually inserted, `false` if the buyer /// already owned the project. Mirrors the `claim_free_item` shape so callers /// can gate downstream side-effects (contact-revocation clear, sale-notification /// email, etc.) on the winner of a concurrent-claim race, without this signal, /// two concurrent `/checkout/project` POSTs both fire those side-effects /// regardless of which one's INSERT actually landed. #[tracing::instrument(skip_all)] pub async fn claim_free_project( pool: &PgPool, buyer_id: UserId, seller_id: UserId, project_id: ProjectId, item_title: &str, seller_username: &str, share_contact: bool, ) -> Result { let result = sqlx::query!( r#" INSERT INTO transactions (buyer_id, seller_id, project_id, amount_cents, platform_fee_cents, status, completed_at, item_title, seller_username, share_contact) VALUES ($1, $2, $3, 0, 0, 'completed', NOW(), $4, $5, $6) ON CONFLICT (buyer_id, project_id) WHERE status = 'completed' AND project_id IS NOT NULL DO NOTHING "#, buyer_id as UserId, seller_id as UserId, project_id as ProjectId, item_title, seller_username, share_contact, ) .execute(pool) .await?; Ok(result.rows_affected() > 0) }