Skip to main content

max / makenotwork

6.5 KB · 174 lines History Blame Raw
1 //! Giving the money back, and proving a webhook has not already been handled.
2
3 use super::super::{ItemId, PgPool, Result, TransactionId};
4
5 /// Atomically claim a completed transaction for refund (`completed -> refunding`).
6 ///
7 /// Returns `Some(id)` only if THIS call won the transition; returns `None` if the
8 /// row was not `completed` (already refunding, already refunded, or gone). The
9 /// self-service refund handler must call this BEFORE issuing the Stripe refund so
10 /// a rapid double-submit cannot pass the refundability check twice and over-refund
11 /// a shared-cart PaymentIntent. On Stripe error the handler calls
12 /// [`release_refund_claim`] to roll the row back to `completed`; on success the
13 /// `refund.created` webhook finalizes `refunding -> refunded`.
14 #[tracing::instrument(skip_all)]
15 pub async fn claim_transaction_for_refund(
16 pool: &PgPool,
17 id: TransactionId,
18 ) -> Result<Option<TransactionId>> {
19 let row = sqlx::query_scalar!(
20 r#"
21 UPDATE transactions
22 SET status = 'refunding'
23 WHERE id = $1 AND status = 'completed'
24 RETURNING id AS "id: TransactionId"
25 "#,
26 id as TransactionId,
27 )
28 .fetch_optional(pool)
29 .await?;
30
31 Ok(row)
32 }
33
34 /// Release a refund claim (`refunding -> completed`) after a Stripe refund call
35 /// failed, so the creator can retry. Idempotent: only a row still in `refunding`
36 /// transitions; a row the webhook already finalized to `refunded` is left alone.
37 #[tracing::instrument(skip_all)]
38 pub async fn release_refund_claim(pool: &PgPool, id: TransactionId) -> Result<()> {
39 sqlx::query!(
40 r#"
41 UPDATE transactions
42 SET status = 'completed'
43 WHERE id = $1 AND status = 'refunding'
44 "#,
45 id as TransactionId,
46 )
47 .execute(pool)
48 .await?;
49
50 Ok(())
51 }
52
53 /// Mark a transaction as refunded, returning its ID and item_id for downstream cleanup.
54 ///
55 /// The WHERE clause requires `status IN ('completed', 'refunding')` so that
56 /// already-refunded or pending transactions are not double-processed, while a row
57 /// the self-service handler has claimed (`refunding`) still finalizes. Returns an
58 /// empty vec if no matching transactions were found (idempotent for webhook retries).
59 ///
60 /// Returns ALL refunded transactions (handles cart checkouts where multiple
61 /// transactions share the same payment_intent_id).
62 ///
63 /// FULL-INTENT scope, and `pub(crate)` so only in-crate webhook handlers can
64 /// mint it: a single cart line must use the line-scoped
65 /// [`refund_transaction_by_id`] instead, never this PI-wide UPDATE, which would
66 /// refund a whole cart from one line's event.
67 #[tracing::instrument(skip_all)]
68 pub(crate) async fn refund_transaction_by_payment_intent<'e>(
69 executor: impl sqlx::PgExecutor<'e>,
70 payment_intent_id: &str,
71 ) -> Result<Vec<(crate::db::TransactionId, Option<ItemId>)>> {
72 // item_id is nullable on project-level transactions (routes/stripe/checkout/project.rs);
73 // returning non-Optional ItemId would cause sqlx decode failures and infinite Stripe retries.
74 let rows = sqlx::query!(
75 r#"
76 UPDATE transactions
77 SET status = 'refunded'
78 WHERE stripe_payment_intent_id = $1 AND status IN ('completed', 'refunding')
79 RETURNING id AS "id: crate::db::TransactionId", item_id AS "item_id: ItemId"
80 "#,
81 payment_intent_id,
82 )
83 .fetch_all(executor)
84 .await?;
85
86 Ok(rows.into_iter().map(|r| (r.id, r.item_id)).collect())
87 }
88
89 /// Mark a SINGLE transaction refunded by id, returning `(id, item_id)` if it
90 /// transitioned from `completed` or `refunding` (the self-service handler claims
91 /// the row to `refunding` before calling Stripe). Returns `None` if it was already
92 /// refunded or otherwise not refundable (idempotent for webhook re-delivery).
93 ///
94 /// Used by the line-scoped `refund.created` handler: cart lines share a
95 /// payment_intent, so refunding one line must touch only its own row, never the
96 /// PI-wide [`refund_transaction_by_payment_intent`].
97 #[tracing::instrument(skip_all)]
98 pub(crate) async fn refund_transaction_by_id<'e>(
99 executor: impl sqlx::PgExecutor<'e>,
100 id: TransactionId,
101 ) -> Result<Option<(crate::db::TransactionId, Option<ItemId>)>> {
102 let row = sqlx::query!(
103 r#"
104 UPDATE transactions
105 SET status = 'refunded'
106 WHERE id = $1 AND status IN ('completed', 'refunding')
107 RETURNING id AS "id: crate::db::TransactionId", item_id AS "item_id: ItemId"
108 "#,
109 id as TransactionId,
110 )
111 .fetch_optional(executor)
112 .await?;
113
114 Ok(row.map(|r| (r.id, r.item_id)))
115 }
116
117 /// True if any transaction (any status) references this payment_intent. Lets the
118 /// `charge.refunded` handler tell "already refunded" (line-scoped refunds marked
119 /// the rows) apart from "genuinely unmatched" before queuing a pending refund.
120 pub async fn transaction_exists_for_payment_intent<'e>(
121 executor: impl sqlx::PgExecutor<'e>,
122 payment_intent_id: &str,
123 ) -> Result<bool> {
124 let exists = sqlx::query_scalar!(
125 r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_payment_intent_id = $1) AS "exists!""#,
126 payment_intent_id,
127 )
128 .fetch_one(executor)
129 .await?;
130
131 Ok(exists)
132 }
133
134 /// True if any transaction (any status) references this checkout session. Lets
135 /// the cart-completion webhook tell a benign duplicate delivery (rows already
136 /// completed) apart from an ORPHANED paid session (rows never created, buyer
137 /// charged, got nothing) so the latter is escalated.
138 pub async fn transaction_exists_for_checkout_session<'e>(
139 executor: impl sqlx::PgExecutor<'e>,
140 checkout_session_id: &str,
141 ) -> Result<bool> {
142 let exists = sqlx::query_scalar!(
143 r#"SELECT EXISTS(SELECT 1 FROM transactions WHERE stripe_checkout_session_id = $1) AS "exists!""#,
144 checkout_session_id,
145 )
146 .fetch_one(executor)
147 .await?;
148
149 Ok(exists)
150 }
151
152 /// Revoke all child transactions linked to a parent (bundle) transaction.
153 ///
154 /// Returns the item IDs of revoked children so callers can decrement sales counts.
155 #[tracing::instrument(skip_all)]
156 pub async fn revoke_child_transactions<'e>(
157 executor: impl sqlx::PgExecutor<'e>,
158 parent_transaction_id: TransactionId,
159 ) -> Result<Vec<ItemId>> {
160 let item_ids = sqlx::query_scalar!(
161 r#"
162 UPDATE transactions
163 SET status = 'refunded'
164 WHERE parent_transaction_id = $1 AND status = 'completed'
165 RETURNING item_id AS "item_id: ItemId"
166 "#,
167 parent_transaction_id as TransactionId,
168 )
169 .fetch_all(executor)
170 .await?;
171
172 Ok(item_ids.into_iter().flatten().collect())
173 }
174