Skip to main content

max / makenotwork

3.7 KB · 108 lines History Blame Raw
1 //! Self-service refund endpoint for creators.
2
3 use axum::Json;
4 use axum::extract::{Path, State};
5 use axum::response::IntoResponse;
6 use serde::Deserialize;
7 use sqlx::PgPool;
8
9 use crate::{
10 Billing,
11 auth::AuthUser,
12 db::{self, ItemId, TransactionId},
13 error::{AppError, Result},
14 };
15
16 use super::super::verify_item_ownership;
17
18 #[derive(Debug, Deserialize)]
19 pub(crate) struct RefundRequest {
20 pub transaction_id: TransactionId,
21 }
22
23 /// Issue a line-scoped refund for one transaction on this item.
24 ///
25 /// The refund is sent to Stripe for this transaction's amount only, tagged with
26 /// the transaction id; the `refund.created` webhook marks THAT transaction
27 /// refunded, revokes its license keys, and decrements its sales count. Cart
28 /// orders put every line under one PaymentIntent, so refunding the whole PI
29 /// would silently reverse the entire order (Run #2 Payments SERIOUS).
30 #[tracing::instrument(skip_all, name = "items::refund_transaction")]
31 pub(in crate::routes::api) async fn refund_transaction(
32 State(db): State<PgPool>,
33 State(payments): State<Billing>,
34 AuthUser(user): AuthUser,
35 Path(id): Path<ItemId>,
36 Json(req): Json<RefundRequest>,
37 ) -> Result<impl IntoResponse> {
38 user.check_not_suspended()?;
39 verify_item_ownership(&db, id, user.id).await?;
40
41 // Fetch the transaction and validate it belongs to this item
42 let tx = db::transactions::get_transaction_by_id(&db, req.transaction_id)
43 .await?
44 .ok_or(AppError::NotFound)?;
45
46 if tx.item_id != Some(id) {
47 return Err(AppError::Forbidden);
48 }
49 if tx.seller_id != Some(user.id) {
50 return Err(AppError::Forbidden);
51 }
52 if tx.status != db::TransactionStatus::Completed {
53 return Err(AppError::BadRequest(
54 "Transaction is not in a refundable state".into(),
55 ));
56 }
57
58 let payment_intent_id = tx.stripe_payment_intent_id.as_deref().ok_or_else(|| {
59 AppError::BadRequest("No payment intent, free claims cannot be refunded".into())
60 })?;
61
62 // Get the creator's Stripe connected account ID
63 let seller = db::users::get_user_by_id(&db, user.id)
64 .await?
65 .ok_or(AppError::NotFound)?;
66 let stripe_account_id = seller
67 .stripe_account_id
68 .as_deref()
69 .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?;
70
71 let stripe = payments
72 .stripe
73 .as_ref()
74 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
75
76 // Atomically claim the row (completed -> refunding) BEFORE calling Stripe. The
77 // status above is read from a non-locking fetch; without this claim a rapid
78 // double-submit would pass that check twice (the row stays `completed` until the
79 // async refund.created webhook) and, on a shared-cart PaymentIntent, the second
80 // refund would consume another line's refundable balance (Pay-S1, Run 9).
81 if db::transactions::claim_transaction_for_refund(&db, tx.id)
82 .await?
83 .is_none()
84 {
85 return Err(AppError::BadRequest(
86 "A refund for this transaction is already in progress".into(),
87 ));
88 }
89
90 // Issue the line-scoped refund via Stripe, the refund.created webhook marks
91 // and revokes exactly this transaction (cart orders share a PaymentIntent). On
92 // failure, release the claim so the creator can retry.
93 if let Err(e) = stripe
94 .create_refund_for_transaction(
95 payment_intent_id,
96 stripe_account_id,
97 tx.amount_cents.as_i64(),
98 tx.id,
99 )
100 .await
101 {
102 db::transactions::release_refund_claim(&db, tx.id).await?;
103 return Err(e);
104 }
105
106 Ok(Json(serde_json::json!({ "ok": true })))
107 }
108