Skip to main content

max / makenotwork

13.3 KB · 457 lines History Blame Raw
1 //! Shopping cart queries: fans batch items for combined checkout.
2
3 use chrono::{DateTime, Duration, Utc};
4 use sqlx::PgPool;
5
6 use super::{ItemId, UserId};
7 use crate::error::Result;
8
9 /// Check if an item is in the user's cart.
10 #[tracing::instrument(skip_all)]
11 pub async fn is_in_cart(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<bool> {
12 let exists: bool = sqlx::query_scalar(
13 "SELECT EXISTS(SELECT 1 FROM cart_items WHERE user_id = $1 AND item_id = $2)",
14 )
15 .bind(user_id)
16 .bind(item_id)
17 .fetch_one(pool)
18 .await?;
19
20 Ok(exists)
21 }
22
23 /// Add an item to the user's cart (idempotent).
24 #[tracing::instrument(skip_all)]
25 pub async fn add_to_cart(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> {
26 sqlx::query(
27 "INSERT INTO cart_items (user_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
28 )
29 .bind(user_id)
30 .bind(item_id)
31 .execute(pool)
32 .await?;
33
34 Ok(())
35 }
36
37 /// Remove an item from the user's cart.
38 #[tracing::instrument(skip_all)]
39 pub async fn remove_from_cart(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> {
40 sqlx::query("DELETE FROM cart_items WHERE user_id = $1 AND item_id = $2")
41 .bind(user_id)
42 .bind(item_id)
43 .execute(pool)
44 .await?;
45
46 Ok(())
47 }
48
49 /// Bulk-remove items from the user's cart in a single roundtrip. Used by the
50 /// cart checkout free-claim loop to replace N per-item DELETEs (Run #8 perf
51 /// MED). No-op on empty slice.
52 #[tracing::instrument(skip_all)]
53 pub async fn remove_from_cart_bulk(pool: &PgPool, user_id: UserId, item_ids: &[ItemId]) -> Result<()> {
54 if item_ids.is_empty() {
55 return Ok(());
56 }
57 sqlx::query("DELETE FROM cart_items WHERE user_id = $1 AND item_id = ANY($2)")
58 .bind(user_id)
59 .bind(item_ids)
60 .execute(pool)
61 .await?;
62 Ok(())
63 }
64
65 /// Update the PWYW amount for a cart item.
66 #[tracing::instrument(skip_all)]
67 pub async fn update_cart_amount(
68 pool: &PgPool,
69 user_id: UserId,
70 item_id: ItemId,
71 amount_cents: Option<i32>,
72 ) -> Result<bool> {
73 let result = sqlx::query(
74 "UPDATE cart_items SET amount_cents = $3 WHERE user_id = $1 AND item_id = $2",
75 )
76 .bind(user_id)
77 .bind(item_id)
78 .bind(amount_cents)
79 .execute(pool)
80 .await?;
81
82 Ok(result.rows_affected() > 0)
83 }
84
85 /// Get the number of items in the user's cart.
86 #[tracing::instrument(skip_all)]
87 pub async fn get_cart_count(pool: &PgPool, user_id: UserId) -> Result<i64> {
88 let count: i64 = sqlx::query_scalar(
89 "SELECT COUNT(*) FROM cart_items WHERE user_id = $1",
90 )
91 .bind(user_id)
92 .fetch_one(pool)
93 .await?;
94
95 Ok(count)
96 }
97
98 /// Pre-flight check for cart toggle: fetches item existence, visibility, ownership,
99 /// purchase status, and cart membership in a single query.
100 #[derive(Debug, sqlx::FromRow)]
101 pub struct CartTogglePreflight {
102 pub is_public: bool,
103 pub is_owner: bool,
104 pub has_purchased: bool,
105 pub in_cart: bool,
106 pub listed: bool,
107 }
108
109 /// Single-query pre-flight for cart toggle. Returns `None` if item does not exist.
110 #[tracing::instrument(skip_all)]
111 pub async fn toggle_cart_preflight(
112 pool: &PgPool,
113 user_id: UserId,
114 item_id: ItemId,
115 ) -> Result<Option<CartTogglePreflight>> {
116 let row = sqlx::query_as::<_, CartTogglePreflight>(
117 r#"
118 SELECT
119 i.is_public,
120 (p.user_id = $2) AS is_owner,
121 EXISTS(
122 SELECT 1 FROM transactions t
123 WHERE t.item_id = $1 AND t.buyer_id = $2 AND t.status = 'completed'
124 ) AS has_purchased,
125 EXISTS(
126 SELECT 1 FROM cart_items c
127 WHERE c.item_id = $1 AND c.user_id = $2
128 ) AS in_cart,
129 i.listed AS listed
130 FROM items i
131 JOIN projects p ON i.project_id = p.id
132 WHERE i.id = $1 AND i.deleted_at IS NULL
133 "#,
134 )
135 .bind(item_id)
136 .bind(user_id)
137 .fetch_optional(pool)
138 .await?;
139
140 Ok(row)
141 }
142
143 /// A cart item with joined display and checkout data.
144 #[derive(Debug, Clone, sqlx::FromRow)]
145 pub struct CartItem {
146 pub item_id: ItemId,
147 pub title: String,
148 pub item_type: String,
149 pub price_cents: i32,
150 pub pwyw_enabled: bool,
151 pub pwyw_min_cents: Option<i32>,
152 /// Buyer's chosen PWYW amount (None = use item minimum).
153 pub amount_cents: Option<i32>,
154 pub creator_username: String,
155 pub seller_id: UserId,
156 pub seller_stripe_account_id: Option<String>,
157 pub seller_charges_enabled: bool,
158 pub project_slug: String,
159 pub added_at: DateTime<Utc>,
160 /// Pulled through so the free-claim path can decide bundle/license issuance
161 /// without an extra `get_item_by_id` per item (Run #8 cart N+1 fix).
162 pub enable_license_keys: bool,
163 pub default_max_activations: Option<i32>,
164 }
165
166 impl CartItem {
167 /// Effective price for cart checkout. For PWYW: buyer's chosen amount if set,
168 /// otherwise the item minimum. For fixed: the item price.
169 pub fn effective_price_cents(&self) -> i32 {
170 if self.pwyw_enabled {
171 let min = self.pwyw_min_cents.unwrap_or(0);
172 self.amount_cents
173 .unwrap_or(min)
174 .max(min)
175 .max(0)
176 } else {
177 self.price_cents
178 }
179 }
180
181 /// Whether this item is free at its effective cart price.
182 pub fn is_free(&self) -> bool {
183 self.effective_price_cents() == 0
184 }
185
186 /// Minimum price in dollars for PWYW display.
187 pub fn pwyw_min_dollars(&self) -> String {
188 let min = self.pwyw_min_cents.unwrap_or(0).max(0);
189 format!("{}.{:02}", min / 100, min % 100)
190 }
191
192 /// Bare "X.YY" dollars string for use as a numeric `<input>` value.
193 pub fn effective_price_input_value(&self) -> String {
194 let cents = self.effective_price_cents().max(0);
195 format!("{}.{:02}", cents / 100, cents % 100)
196 }
197
198 /// "$X.YY" display string. Always shows decimals; caller branches on `is_free()`.
199 pub fn effective_price_display(&self) -> String {
200 crate::formatting::format_revenue(self.effective_price_cents() as i64)
201 }
202 }
203
204 /// Get all cart items for a user with joined item, project, and seller data.
205 /// Only returns items that are still public and not deleted.
206 #[tracing::instrument(skip_all)]
207 pub async fn get_cart_items(pool: &PgPool, user_id: UserId) -> Result<Vec<CartItem>> {
208 let items = sqlx::query_as::<_, CartItem>(
209 r#"
210 SELECT c.item_id, i.title, i.item_type::TEXT as item_type,
211 i.price_cents, i.pwyw_enabled, i.pwyw_min_cents,
212 c.amount_cents,
213 u.username AS creator_username, p.user_id AS seller_id,
214 u.stripe_account_id AS seller_stripe_account_id,
215 u.stripe_charges_enabled AS seller_charges_enabled,
216 p.slug AS project_slug,
217 c.created_at AS added_at,
218 i.enable_license_keys,
219 i.default_max_activations
220 FROM cart_items c
221 JOIN items i ON i.id = c.item_id
222 JOIN projects p ON p.id = i.project_id
223 JOIN users u ON u.id = p.user_id
224 WHERE c.user_id = $1
225 AND i.is_public = true
226 AND i.listed = true
227 AND i.deleted_at IS NULL
228 ORDER BY u.username, c.created_at DESC
229 "#,
230 )
231 .bind(user_id)
232 .fetch_all(pool)
233 .await?;
234
235 Ok(items)
236 }
237
238 /// Get cart items for a user filtered to a specific seller.
239 #[tracing::instrument(skip_all)]
240 pub async fn get_cart_items_for_seller(
241 pool: &PgPool,
242 user_id: UserId,
243 seller_id: UserId,
244 ) -> Result<Vec<CartItem>> {
245 let items = sqlx::query_as::<_, CartItem>(
246 r#"
247 SELECT c.item_id, i.title, i.item_type::TEXT as item_type,
248 i.price_cents, i.pwyw_enabled, i.pwyw_min_cents,
249 c.amount_cents,
250 u.username AS creator_username, p.user_id AS seller_id,
251 u.stripe_account_id AS seller_stripe_account_id,
252 u.stripe_charges_enabled AS seller_charges_enabled,
253 p.slug AS project_slug,
254 c.created_at AS added_at,
255 i.enable_license_keys,
256 i.default_max_activations
257 FROM cart_items c
258 JOIN items i ON i.id = c.item_id
259 JOIN projects p ON p.id = i.project_id
260 JOIN users u ON u.id = p.user_id
261 WHERE c.user_id = $1
262 AND p.user_id = $2
263 AND i.is_public = true
264 AND i.listed = true
265 AND i.deleted_at IS NULL
266 ORDER BY c.created_at DESC
267 "#,
268 )
269 .bind(user_id)
270 .bind(seller_id)
271 .fetch_all(pool)
272 .await?;
273
274 Ok(items)
275 }
276
277 /// Remove all cart items belonging to a specific seller (after checkout).
278 #[tracing::instrument(skip_all)]
279 pub async fn remove_seller_items_from_cart(
280 pool: &PgPool,
281 user_id: UserId,
282 seller_id: UserId,
283 ) -> Result<u64> {
284 let result = sqlx::query(
285 r#"
286 DELETE FROM cart_items c
287 USING items i
288 JOIN projects p ON p.id = i.project_id
289 WHERE c.user_id = $1
290 AND c.item_id = i.id
291 AND p.user_id = $2
292 "#,
293 )
294 .bind(user_id)
295 .bind(seller_id)
296 .execute(pool)
297 .await?;
298
299 Ok(result.rows_affected())
300 }
301
302 /// Remove stale cart items older than the given duration.
303 #[tracing::instrument(skip_all)]
304 pub async fn cleanup_stale_cart_items(pool: &PgPool, older_than: Duration) -> Result<u64> {
305 let cutoff = Utc::now() - older_than;
306 let result = sqlx::query("DELETE FROM cart_items WHERE created_at < $1")
307 .bind(cutoff)
308 .execute(pool)
309 .await?;
310
311 Ok(result.rows_affected())
312 }
313
314 /// Remove cart items for items that are no longer available (unpublished, deleted).
315 #[tracing::instrument(skip_all)]
316 pub async fn cleanup_unavailable_cart_items(pool: &PgPool) -> Result<u64> {
317 let result = sqlx::query(
318 r#"
319 DELETE FROM cart_items c
320 USING items i
321 WHERE c.item_id = i.id
322 AND (i.is_public = false OR i.listed = false OR i.deleted_at IS NOT NULL)
323 "#,
324 )
325 .execute(pool)
326 .await?;
327
328 Ok(result.rows_affected())
329 }
330
331 #[cfg(test)]
332 mod tests {
333 use super::*;
334 use chrono::Utc;
335
336 fn make_cart_item(
337 price_cents: i32,
338 pwyw_enabled: bool,
339 pwyw_min_cents: Option<i32>,
340 amount_cents: Option<i32>,
341 ) -> CartItem {
342 CartItem {
343 item_id: ItemId::nil(),
344 title: String::new(),
345 item_type: String::from("audio"),
346 price_cents,
347 pwyw_enabled,
348 pwyw_min_cents,
349 amount_cents,
350 creator_username: String::new(),
351 seller_id: UserId::nil(),
352 seller_stripe_account_id: None,
353 seller_charges_enabled: false,
354 project_slug: String::new(),
355 added_at: Utc::now(),
356 enable_license_keys: false,
357 default_max_activations: None,
358 }
359 }
360
361 // ---- effective_price_cents ----
362
363 #[test]
364 fn fixed_price_returns_price_cents() {
365 let item = make_cart_item(500, false, None, None);
366 assert_eq!(item.effective_price_cents(), 500);
367 }
368
369 #[test]
370 fn pwyw_amount_above_min_returns_amount() {
371 let item = make_cart_item(0, true, Some(300), Some(500));
372 assert_eq!(item.effective_price_cents(), 500);
373 }
374
375 #[test]
376 fn pwyw_amount_below_min_clamps_to_min() {
377 let item = make_cart_item(0, true, Some(500), Some(200));
378 assert_eq!(item.effective_price_cents(), 500);
379 }
380
381 #[test]
382 fn pwyw_no_amount_uses_min() {
383 let item = make_cart_item(0, true, Some(400), None);
384 assert_eq!(item.effective_price_cents(), 400);
385 }
386
387 #[test]
388 fn pwyw_no_min_defaults_to_zero() {
389 let item = make_cart_item(0, true, None, Some(700));
390 assert_eq!(item.effective_price_cents(), 700);
391 }
392
393 #[test]
394 fn pwyw_negative_amount_clamps_to_zero() {
395 let item = make_cart_item(0, true, Some(0), Some(-100));
396 assert_eq!(item.effective_price_cents(), 0);
397 }
398
399 #[test]
400 fn pwyw_both_none_returns_zero() {
401 let item = make_cart_item(0, true, None, None);
402 assert_eq!(item.effective_price_cents(), 0);
403 }
404
405 // ---- is_free ----
406
407 #[test]
408 fn fixed_price_zero_is_free() {
409 let item = make_cart_item(0, false, None, None);
410 assert!(item.is_free());
411 }
412
413 #[test]
414 fn fixed_price_one_is_not_free() {
415 let item = make_cart_item(1, false, None, None);
416 assert!(!item.is_free());
417 }
418
419 #[test]
420 fn pwyw_zero_min_none_amount_is_free() {
421 let item = make_cart_item(0, true, Some(0), None);
422 assert!(item.is_free());
423 }
424
425 // ---- pwyw_min_dollars ----
426
427 #[test]
428 fn pwyw_min_dollars_zero() {
429 let item = make_cart_item(0, true, Some(0), None);
430 assert_eq!(item.pwyw_min_dollars(), "0.00");
431 }
432
433 #[test]
434 fn pwyw_min_dollars_one_dollar() {
435 let item = make_cart_item(0, true, Some(100), None);
436 assert_eq!(item.pwyw_min_dollars(), "1.00");
437 }
438
439 #[test]
440 fn pwyw_min_dollars_one_fifty() {
441 let item = make_cart_item(0, true, Some(150), None);
442 assert_eq!(item.pwyw_min_dollars(), "1.50");
443 }
444
445 #[test]
446 fn pwyw_min_dollars_ninety_nine_cents() {
447 let item = make_cart_item(0, true, Some(99), None);
448 assert_eq!(item.pwyw_min_dollars(), "0.99");
449 }
450
451 #[test]
452 fn pwyw_min_dollars_none_returns_zero() {
453 let item = make_cart_item(0, true, None, None);
454 assert_eq!(item.pwyw_min_dollars(), "0.00");
455 }
456 }
457