Skip to main content

max / makenotwork

4.2 KB · 113 lines History Blame Raw
1 //! The creator-initiated refund, in one place.
2 //!
3 //! Two routes need it, and a money path copied is a money path with two
4 //! behaviours. [`refund`] holds every
5 //! check and the claim; `routes::api::items::refund_transaction` is a thin
6 //! axum wrapper over it, and the described Sales panel
7 //! (`crate::quasi::item_sales`) calls it from a writes-only nest so its Refund
8 //! button can answer with the panel rather than a toast the dispatcher has to
9 //! patch over.
10 //!
11 //! It lives under `payments` rather than beside either caller: a refund is a
12 //! money path, not a render path, and putting it in one route's module would
13 //! make the other one's call read like a shortcut into somebody else's handler.
14
15 use std::sync::Arc;
16
17 use sqlx::PgPool;
18
19 use crate::auth::SessionUser;
20 use crate::db::{self, ItemId, TransactionId};
21 use crate::error::{AppError, Result};
22 use crate::payments::Refundable;
23 use crate::routes::api::verify_item_ownership;
24
25 /// Issue a line-scoped refund for one transaction on this item.
26 ///
27 /// The refund is sent to Stripe for this transaction's amount only, tagged with
28 /// the transaction id; the `refund.created` webhook marks THAT transaction
29 /// refunded, revokes its license keys, and decrements its sales count. Cart
30 /// orders put every line under one PaymentIntent, so refunding the whole PI
31 /// would silently reverse the entire order.
32 ///
33 /// Every caller's authorization is here rather than at the caller: the item
34 /// ownership check, the transaction belonging to this item and this seller, and
35 /// the suspension check. A caller that had to remember to do those first is a
36 /// caller that can forget.
37 #[tracing::instrument(skip_all, name = "payments::refund")]
38 pub async fn refund(
39 db: &PgPool,
40 stripe: Option<&Arc<dyn Refundable>>,
41 user: &SessionUser,
42 item: ItemId,
43 transaction: TransactionId,
44 ) -> Result<()> {
45 user.check_not_suspended()?;
46 verify_item_ownership(db, item, user.id).await?;
47
48 // Fetch the transaction and validate it belongs to this item
49 let tx = db::transactions::get_transaction_by_id(db, transaction)
50 .await?
51 .ok_or(AppError::NotFound)?;
52
53 if tx.item_id != Some(item) {
54 return Err(AppError::Forbidden);
55 }
56 if tx.seller_id != Some(user.id) {
57 return Err(AppError::Forbidden);
58 }
59 if tx.status != db::TransactionStatus::Completed {
60 return Err(AppError::BadRequest(
61 "Transaction is not in a refundable state".into(),
62 ));
63 }
64
65 let payment_intent_id = tx.stripe_payment_intent_id.as_deref().ok_or_else(|| {
66 AppError::BadRequest("No payment intent, free claims cannot be refunded".into())
67 })?;
68
69 // Get the creator's Stripe connected account ID
70 let seller = db::users::get_user_by_id(db, user.id)
71 .await?
72 .ok_or(AppError::NotFound)?;
73 let stripe_account_id = seller
74 .stripe_account_id
75 .as_deref()
76 .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?;
77
78 let stripe = stripe
79 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
80
81 // Atomically claim the row (completed -> refunding) BEFORE calling Stripe. The
82 // status above is read from a non-locking fetch; without this claim a rapid
83 // double-submit would pass that check twice (the row stays `completed` until the
84 // async refund.created webhook) and, on a shared-cart PaymentIntent, the second
85 // refund would consume another line's refundable balance (Pay-S1, Run 9).
86 if db::transactions::claim_transaction_for_refund(db, tx.id)
87 .await?
88 .is_none()
89 {
90 return Err(AppError::BadRequest(
91 "A refund for this transaction is already in progress".into(),
92 ));
93 }
94
95 // Issue the line-scoped refund via Stripe, the refund.created webhook marks
96 // and revokes exactly this transaction (cart orders share a PaymentIntent). On
97 // failure, release the claim so the creator can retry.
98 if let Err(e) = stripe
99 .create_refund_for_transaction(
100 payment_intent_id,
101 stripe_account_id,
102 tx.amount_cents.as_i64(),
103 tx.id,
104 )
105 .await
106 {
107 db::transactions::release_refund_claim(db, tx.id).await?;
108 return Err(e);
109 }
110
111 Ok(())
112 }
113