//! The creator-initiated refund, in one place. //! //! Two routes need it, and a money path copied is a money path with two //! behaviours. [`refund`] holds every //! check and the claim; `routes::api::items::refund_transaction` is a thin //! axum wrapper over it, and the described Sales panel //! (`crate::quasi::item_sales`) calls it from a writes-only nest so its Refund //! button can answer with the panel rather than a toast the dispatcher has to //! patch over. //! //! It lives under `payments` rather than beside either caller: a refund is a //! money path, not a render path, and putting it in one route's module would //! make the other one's call read like a shortcut into somebody else's handler. use std::sync::Arc; use sqlx::PgPool; use crate::auth::SessionUser; use crate::db::{self, ItemId, TransactionId}; use crate::error::{AppError, Result}; use crate::payments::Refundable; use crate::routes::api::verify_item_ownership; /// Issue a line-scoped refund for one transaction on this item. /// /// The refund is sent to Stripe for this transaction's amount only, tagged with /// the transaction id; the `refund.created` webhook marks THAT transaction /// refunded, revokes its license keys, and decrements its sales count. Cart /// orders put every line under one PaymentIntent, so refunding the whole PI /// would silently reverse the entire order. /// /// Every caller's authorization is here rather than at the caller: the item /// ownership check, the transaction belonging to this item and this seller, and /// the suspension check. A caller that had to remember to do those first is a /// caller that can forget. #[tracing::instrument(skip_all, name = "payments::refund")] pub async fn refund( db: &PgPool, stripe: Option<&Arc>, user: &SessionUser, item: ItemId, transaction: TransactionId, ) -> Result<()> { user.check_not_suspended()?; verify_item_ownership(db, item, user.id).await?; // Fetch the transaction and validate it belongs to this item let tx = db::transactions::get_transaction_by_id(db, transaction) .await? .ok_or(AppError::NotFound)?; if tx.item_id != Some(item) { return Err(AppError::Forbidden); } if tx.seller_id != Some(user.id) { return Err(AppError::Forbidden); } if tx.status != db::TransactionStatus::Completed { return Err(AppError::BadRequest( "Transaction is not in a refundable state".into(), )); } let payment_intent_id = tx.stripe_payment_intent_id.as_deref().ok_or_else(|| { AppError::BadRequest("No payment intent, free claims cannot be refunded".into()) })?; // Get the creator's Stripe connected account ID let seller = db::users::get_user_by_id(db, user.id) .await? .ok_or(AppError::NotFound)?; let stripe_account_id = seller .stripe_account_id .as_deref() .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?; let stripe = stripe .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; // Atomically claim the row (completed -> refunding) BEFORE calling Stripe. The // status above is read from a non-locking fetch; without this claim a rapid // double-submit would pass that check twice (the row stays `completed` until the // async refund.created webhook) and, on a shared-cart PaymentIntent, the second // refund would consume another line's refundable balance (Pay-S1, Run 9). if db::transactions::claim_transaction_for_refund(db, tx.id) .await? .is_none() { return Err(AppError::BadRequest( "A refund for this transaction is already in progress".into(), )); } // Issue the line-scoped refund via Stripe, the refund.created webhook marks // and revokes exactly this transaction (cart orders share a PaymentIntent). On // failure, release the claim so the creator can retry. if let Err(e) = stripe .create_refund_for_transaction( payment_intent_id, stripe_account_id, tx.amount_cents.as_i64(), tx.id, ) .await { db::transactions::release_refund_claim(db, tx.id).await?; return Err(e); } Ok(()) }