Skip to main content

max / makenotwork

11.7 KB · 308 lines History Blame Raw
1 //! Taking something that costs nothing.
2 //!
3 //! Every function here writes `amount_cents = 0, status = 'completed'` through
4 //! the same `ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND
5 //! item_id IS NOT NULL DO NOTHING` idempotency clause, and three carry
6 //! `platform_credit_cents` for the Fan+ reimbursement.
7
8 use super::super::{
9 ItemId, KeyCode, PgPool, ProjectId, PromoCodeId, Result, TransactionId, UserId,
10 };
11
12 /// Common parameters for claiming a free item (direct, discount code, or download code).
13 pub struct ClaimParams<'a> {
14 pub buyer_id: UserId,
15 pub item_id: ItemId,
16 pub seller_id: UserId,
17 pub item_title: &'a str,
18 pub seller_username: &'a str,
19 pub share_contact: bool,
20 /// If this claim was granted via a bundle purchase, the parent transaction ID.
21 pub parent_transaction_id: Option<TransactionId>,
22 /// Cents MNW owes the seller when a platform-wide (Fan+) credit made this item
23 /// free, the seller is reimbursed the item's price via a platform transfer so
24 /// they are still paid. `0` for ordinary free claims (genuinely-free items,
25 /// seller-issued free-access codes, bundle grants).
26 pub platform_credit_cents: i64,
27 }
28
29 /// Claims a free item by creating a zero-cost completed transaction.
30 /// Returns true if claimed successfully, false if already in library.
31 ///
32 /// Uses `ON CONFLICT DO NOTHING` against the partial unique index on
33 /// `(buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL` to prevent duplicate
34 /// claims under concurrent requests.
35 #[tracing::instrument(skip_all)]
36 pub async fn claim_free_item<'e>(
37 executor: impl sqlx::PgExecutor<'e>,
38 params: &ClaimParams<'_>,
39 ) -> Result<bool> {
40 let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
41 let result = sqlx::query!(
42 r#"
43 INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, parent_transaction_id, platform_credit_cents)
44 VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9)
45 ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING
46 "#,
47 params.buyer_id as UserId,
48 params.seller_id as UserId,
49 params.item_id as ItemId,
50 claim_id,
51 params.item_title,
52 params.seller_username,
53 params.share_contact,
54 params.parent_transaction_id as Option<TransactionId>,
55 params.platform_credit_cents,
56 )
57 .execute(executor)
58 .await?;
59
60 Ok(result.rows_affected() > 0)
61 }
62
63 /// Batch variant of [`claim_free_item`] for bundle grants: claims every child
64 /// item for one buyer in a single INSERT instead of N round-trips (each with its
65 /// own pool acquire) on the Stripe webhook / checkout hot path.
66 /// Each row is idempotent via the same partial-unique ON CONFLICT as the single
67 /// claim, and child items deliberately do not increment `sales_count`. Returns the
68 /// number of rows actually inserted. No-op on an empty slice.
69 #[tracing::instrument(skip_all)]
70 pub async fn claim_free_items_batch<'e>(
71 executor: impl sqlx::PgExecutor<'e>,
72 buyer_id: UserId,
73 seller_id: UserId,
74 seller_username: &str,
75 parent_transaction_id: Option<TransactionId>,
76 items: &[(ItemId, &str)],
77 ) -> Result<u64> {
78 if items.is_empty() {
79 return Ok(0);
80 }
81 let item_ids: Vec<ItemId> = items.iter().map(|(id, _)| *id).collect();
82 let item_titles: Vec<&str> = items.iter().map(|(_, title)| *title).collect();
83
84 // The per-row claim id mirrors the single claim's `free-claim-{buyer}-{item}`
85 // so a later single claim of the same item still collides idempotently.
86 let result = sqlx::query(
87 r"
88 INSERT INTO transactions
89 (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
90 stripe_checkout_session_id, status, completed_at, item_title,
91 seller_username, share_contact, parent_transaction_id)
92 SELECT
93 $1, $2, t.item_id, 0, 0,
94 'free-claim-' || $1::text || '-' || t.item_id::text,
95 'completed', NOW(), t.item_title, $3, false, $4
96 FROM UNNEST($5::uuid[], $6::text[]) AS t(item_id, item_title)
97 ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING
98 ",
99 )
100 .bind(buyer_id)
101 .bind(seller_id)
102 .bind(seller_username)
103 .bind(parent_transaction_id)
104 .bind(&item_ids)
105 .bind(&item_titles)
106 .execute(executor)
107 .await?;
108
109 Ok(result.rows_affected())
110 }
111
112 /// Optional parameters for generating a license key inside a claim transaction.
113 pub struct LicenseKeyParams<'a> {
114 pub key_code: &'a KeyCode,
115 pub max_activations: Option<i32>,
116 }
117
118 /// Atomically claim a free item and increment the promo code's use count.
119 ///
120 /// Claims the item FIRST (INSERT transaction), then increments use_count.
121 /// If the user already owns the item (rows_affected == 0), rolls back without
122 /// consuming the code. If the code limit is reached, rolls back the claim too.
123 ///
124 /// When `license_key_params` is `Some`, a license key is created inside the
125 /// same transaction so that the claim and key are always consistent.
126 ///
127 /// Returns `(code_accepted, item_claimed)`:
128 /// - `code_accepted = false` → promo code hit its usage limit (nothing changed)
129 /// - `item_claimed = false` → user already owns the item (code was NOT consumed)
130 #[tracing::instrument(skip_all)]
131 pub async fn claim_free_with_promo_code(
132 pool: &PgPool,
133 promo_code_id: PromoCodeId,
134 params: &ClaimParams<'_>,
135 license_key_params: Option<&LicenseKeyParams<'_>>,
136 ) -> Result<(bool, bool)> {
137 let mut tx = pool.begin().await?;
138
139 // Claim the item first. When a platform-wide credit (Fan+)
140 // made the item free, `platform_credit_cents` carries the item's full price so
141 // the scheduler reimburses the creator via transfer, the fan pays nothing but
142 // the creator is still paid (MNW funds it).
143 let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
144 let result = sqlx::query!(
145 r#"
146 INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, promo_code_id, platform_credit_cents)
147 VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8, $9)
148 ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' AND item_id IS NOT NULL DO NOTHING
149 "#,
150 params.buyer_id as UserId,
151 params.seller_id as UserId,
152 params.item_id as ItemId,
153 claim_id,
154 params.item_title,
155 params.seller_username,
156 params.share_contact,
157 promo_code_id as PromoCodeId,
158 params.platform_credit_cents,
159 )
160 .execute(&mut *tx)
161 .await?;
162
163 let claimed = result.rows_affected() > 0;
164
165 if !claimed {
166 tx.rollback().await?;
167 return Ok((true, false));
168 }
169
170 // Increment the promo code use count. Re-check the full validity
171 // window (max_uses AND starts_at/expires_at) inside the atomic UPDATE, the
172 // pre-flight check ran outside this transaction, so a code expiring in the
173 // sub-ms gap must still be rejected here, not just on use-count (Run 11 Pay
174 // MINOR / TOCTOU).
175 let code_result = sqlx::query!(
176 r#"
177 UPDATE promo_codes SET use_count = use_count + 1
178 WHERE id = $1
179 AND (max_uses IS NULL OR use_count < max_uses)
180 AND (starts_at IS NULL OR starts_at <= NOW())
181 AND (expires_at IS NULL OR expires_at > NOW())
182 "#,
183 promo_code_id as PromoCodeId,
184 )
185 .execute(&mut *tx)
186 .await?;
187
188 if code_result.rows_affected() == 0 {
189 tx.rollback().await?;
190 return Ok((false, false));
191 }
192
193 crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?;
194
195 // Create the license key inside the same transaction if requested.
196 // Retry once on a unique-violation: the wordlist generator has ~6B-coin-
197 // flip headroom, so an actual collision is vanishingly rare, but the
198 // alternative is surfacing a 500 to a buyer mid-claim, cheap to handle.
199 if let Some(lk) = license_key_params {
200 let attempt = sqlx::query!(
201 r#"
202 INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations)
203 VALUES ($1, $2, NULL, $3, $4)
204 "#,
205 params.item_id as ItemId,
206 params.buyer_id as UserId,
207 lk.key_code as &KeyCode,
208 lk.max_activations,
209 )
210 .execute(&mut *tx)
211 .await;
212
213 if let Err(sqlx::Error::Database(e)) = &attempt
214 && e.code().as_deref() == Some("23505")
215 {
216 let retry_code = crate::helpers::generate_key_code();
217 tracing::warn!(item_id = %params.item_id, "license key 23505 collision; retrying once");
218 sqlx::query!(
219 r#"
220 INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations)
221 VALUES ($1, $2, NULL, $3, $4)
222 "#,
223 params.item_id as ItemId,
224 params.buyer_id as UserId,
225 retry_code as KeyCode,
226 lk.max_activations,
227 )
228 .execute(&mut *tx)
229 .await?;
230 } else {
231 attempt?;
232 }
233 }
234
235 tx.commit().await?;
236 Ok((true, true))
237 }
238
239 /// Remove a free item from library (deletes the claim transaction).
240 /// If the claim was via a promo code, decrements the code's use_count.
241 #[tracing::instrument(skip_all)]
242 pub async fn remove_free_item_from_library(
243 pool: &PgPool,
244 user_id: UserId,
245 item_id: ItemId,
246 ) -> Result<bool> {
247 // Delete the free claim and return the promo_code_id if one was used
248 let row: Option<Option<crate::db::PromoCodeId>> = sqlx::query_scalar!(
249 r#"
250 DELETE FROM transactions
251 WHERE buyer_id = $1 AND item_id = $2 AND amount_cents = 0 AND status = 'completed'
252 RETURNING promo_code_id AS "promo_code_id: crate::db::PromoCodeId"
253 "#,
254 user_id as UserId,
255 item_id as ItemId,
256 )
257 .fetch_optional(pool)
258 .await?;
259
260 let deleted = row.is_some();
261
262 if let Some(Some(pc_id)) = row {
263 crate::db::promo_codes::release_use_count(pool, pc_id)
264 .await
265 .ok();
266 }
267
268 Ok(deleted)
269 }
270
271 /// Record a free project claim (PWYW with $0 min or free project).
272 ///
273 /// Returns `true` if the claim was actually inserted, `false` if the buyer
274 /// already owned the project. Mirrors the `claim_free_item` shape so callers
275 /// can gate downstream side-effects (contact-revocation clear, sale-notification
276 /// email, etc.) on the winner of a concurrent-claim race, without this signal,
277 /// two concurrent `/checkout/project` POSTs both fire those side-effects
278 /// regardless of which one's INSERT actually landed.
279 #[tracing::instrument(skip_all)]
280 pub async fn claim_free_project(
281 pool: &PgPool,
282 buyer_id: UserId,
283 seller_id: UserId,
284 project_id: ProjectId,
285 item_title: &str,
286 seller_username: &str,
287 share_contact: bool,
288 ) -> Result<bool> {
289 let result = sqlx::query!(
290 r#"
291 INSERT INTO transactions (buyer_id, seller_id, project_id, amount_cents, platform_fee_cents,
292 status, completed_at, item_title, seller_username, share_contact)
293 VALUES ($1, $2, $3, 0, 0, 'completed', NOW(), $4, $5, $6)
294 ON CONFLICT (buyer_id, project_id) WHERE status = 'completed' AND project_id IS NOT NULL DO NOTHING
295 "#,
296 buyer_id as UserId,
297 seller_id as UserId,
298 project_id as ProjectId,
299 item_title,
300 seller_username,
301 share_contact,
302 )
303 .execute(pool)
304 .await?;
305
306 Ok(result.rows_affected() > 0)
307 }
308