//! Giving the money back, and proving a webhook has not already been handled. use super::super::{ItemId, PgPool, Result, TransactionId}; /// Atomically claim a completed transaction for refund (`completed -> refunding`). /// /// Returns `Some(id)` only if THIS call won the transition; returns `None` if the /// row was not `completed` (already refunding, already refunded, or gone). The /// self-service refund handler must call this BEFORE issuing the Stripe refund so /// a rapid double-submit cannot pass the refundability check twice and over-refund /// a shared-cart PaymentIntent. On Stripe error the handler calls /// [`release_refund_claim`] to roll the row back to `completed`; on success the /// `refund.created` webhook finalizes `refunding -> refunded`. #[tracing::instrument(skip_all)] pub async fn claim_transaction_for_refund( pool: &PgPool, id: TransactionId, ) -> Result> { let row = sqlx::query_scalar!( r#" UPDATE transactions SET status = 'refunding' WHERE id = $1 AND status = 'completed' RETURNING id AS "id: TransactionId" "#, id as TransactionId, ) .fetch_optional(pool) .await?; Ok(row) } /// Release a refund claim (`refunding -> completed`) after a Stripe refund call /// failed, so the creator can retry. Idempotent: only a row still in `refunding` /// transitions; a row the webhook already finalized to `refunded` is left alone. #[tracing::instrument(skip_all)] pub async fn release_refund_claim(pool: &PgPool, id: TransactionId) -> Result<()> { sqlx::query!( r#" UPDATE transactions SET status = 'completed' WHERE id = $1 AND status = 'refunding' "#, id as TransactionId, ) .execute(pool) .await?; Ok(()) } /// Mark a transaction as refunded, returning its ID and item_id for downstream cleanup. /// /// The WHERE clause requires `status IN ('completed', 'refunding')` so that /// already-refunded or pending transactions are not double-processed, while a row /// the self-service handler has claimed (`refunding`) still finalizes. Returns an /// empty vec if no matching transactions were found (idempotent for webhook retries). /// /// Returns ALL refunded transactions (handles cart checkouts where multiple /// transactions share the same payment_intent_id). /// /// FULL-INTENT scope, and `pub(crate)` so only in-crate webhook handlers can /// mint it: a single cart line must use the line-scoped /// [`refund_transaction_by_id`] instead, never this PI-wide UPDATE, which would /// refund a whole cart from one line's event. #[tracing::instrument(skip_all)] pub(crate) async fn refund_transaction_by_payment_intent<'e>( executor: impl sqlx::PgExecutor<'e>, payment_intent_id: &str, ) -> Result)>> { // item_id is nullable on project-level transactions (routes/stripe/checkout/project.rs); // returning non-Optional ItemId would cause sqlx decode failures and infinite Stripe retries. let rows = sqlx::query!( r#" UPDATE transactions SET status = 'refunded' WHERE stripe_payment_intent_id = $1 AND status IN ('completed', 'refunding') RETURNING id AS "id: crate::db::TransactionId", item_id AS "item_id: ItemId" "#, payment_intent_id, ) .fetch_all(executor) .await?; Ok(rows.into_iter().map(|r| (r.id, r.item_id)).collect()) } /// Mark a SINGLE transaction refunded by id, returning `(id, item_id)` if it /// transitioned from `completed` or `refunding` (the self-service handler claims /// the row to `refunding` before calling Stripe). Returns `None` if it was already /// refunded or otherwise not refundable (idempotent for webhook re-delivery). /// /// Used by the line-scoped `refund.created` handler: cart lines share a /// payment_intent, so refunding one line must touch only its own row, never the /// PI-wide [`refund_transaction_by_payment_intent`]. #[tracing::instrument(skip_all)] pub(crate) async fn refund_transaction_by_id<'e>( executor: impl sqlx::PgExecutor<'e>, id: TransactionId, ) -> Result)>> { let row = sqlx::query!( r#" UPDATE transactions SET status = 'refunded' WHERE id = $1 AND status IN ('completed', 'refunding') RETURNING id AS "id: crate::db::TransactionId", item_id AS "item_id: ItemId" "#, id as TransactionId, ) .fetch_optional(executor) .await?; Ok(row.map(|r| (r.id, r.item_id))) } /// True if any transaction (any status) references this payment_intent. Lets the /// `charge.refunded` handler tell "already refunded" (line-scoped refunds marked /// the rows) apart from "genuinely unmatched" before queuing a pending refund. pub async fn transaction_exists_for_payment_intent<'e>( executor: impl sqlx::PgExecutor<'e>, payment_intent_id: &str, ) -> Result { let exists = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_payment_intent_id = $1) AS "exists!""#, payment_intent_id, ) .fetch_one(executor) .await?; Ok(exists) } /// True if any transaction (any status) references this checkout session. Lets /// the cart-completion webhook tell a benign duplicate delivery (rows already /// completed) apart from an ORPHANED paid session (rows never created, buyer /// charged, got nothing) so the latter is escalated. pub async fn transaction_exists_for_checkout_session<'e>( executor: impl sqlx::PgExecutor<'e>, checkout_session_id: &str, ) -> Result { let exists = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_checkout_session_id = $1) AS "exists!""#, checkout_session_id, ) .fetch_one(executor) .await?; Ok(exists) } /// Revoke all child transactions linked to a parent (bundle) transaction. /// /// Returns the item IDs of revoked children so callers can decrement sales counts. #[tracing::instrument(skip_all)] pub async fn revoke_child_transactions<'e>( executor: impl sqlx::PgExecutor<'e>, parent_transaction_id: TransactionId, ) -> Result> { let item_ids = sqlx::query_scalar!( r#" UPDATE transactions SET status = 'refunded' WHERE parent_transaction_id = $1 AND status = 'completed' RETURNING item_id AS "item_id: ItemId" "#, parent_transaction_id as TransactionId, ) .fetch_all(executor) .await?; Ok(item_ids.into_iter().flatten().collect()) }