//! Self-service refund endpoint for creators. use axum::Json; use axum::extract::{Path, State}; use axum::response::IntoResponse; use serde::Deserialize; use sqlx::PgPool; use crate::{ Billing, auth::AuthUser, db::{self, ItemId, TransactionId}, error::{AppError, Result}, }; use super::super::verify_item_ownership; #[derive(Debug, Deserialize)] pub(crate) struct RefundRequest { pub transaction_id: TransactionId, } /// 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 (Run #2 Payments SERIOUS). #[tracing::instrument(skip_all, name = "items::refund_transaction")] pub(in crate::routes::api) async fn refund_transaction( State(db): State, State(payments): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_item_ownership(&db, id, user.id).await?; // Fetch the transaction and validate it belongs to this item let tx = db::transactions::get_transaction_by_id(&db, req.transaction_id) .await? .ok_or(AppError::NotFound)?; if tx.item_id != Some(id) { 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 = payments .stripe .as_ref() .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(Json(serde_json::json!({ "ok": true }))) }