Skip to main content

max / makenotwork

13.6 KB · 461 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, ProjectId, 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 /// Owning project. Pulled through so project-scoped promo checks don't need
148 /// a `get_item_by_id` per item at checkout.
149 pub project_id: ProjectId,
150 pub title: String,
151 pub item_type: String,
152 pub price_cents: i32,
153 pub pwyw_enabled: bool,
154 pub pwyw_min_cents: Option<i32>,
155 /// Buyer's chosen PWYW amount (None = use item minimum).
156 pub amount_cents: Option<i32>,
157 pub creator_username: String,
158 pub seller_id: UserId,
159 pub seller_stripe_account_id: Option<String>,
160 pub seller_charges_enabled: bool,
161 pub project_slug: String,
162 pub added_at: DateTime<Utc>,
163 /// Pulled through so the free-claim path can decide bundle/license issuance
164 /// without an extra `get_item_by_id` per item (Run #8 cart N+1 fix).
165 pub enable_license_keys: bool,
166 pub default_max_activations: Option<i32>,
167 }
168
169 impl CartItem {
170 /// Effective price for cart checkout. For PWYW: buyer's chosen amount if set,
171 /// otherwise the item minimum. For fixed: the item price.
172 pub fn effective_price_cents(&self) -> i32 {
173 if self.pwyw_enabled {
174 let min = self.pwyw_min_cents.unwrap_or(0);
175 self.amount_cents
176 .unwrap_or(min)
177 .max(min)
178 .max(0)
179 } else {
180 self.price_cents
181 }
182 }
183
184 /// Whether this item is free at its effective cart price.
185 pub fn is_free(&self) -> bool {
186 self.effective_price_cents() == 0
187 }
188
189 /// Minimum price in dollars for PWYW display.
190 pub fn pwyw_min_dollars(&self) -> String {
191 let min = self.pwyw_min_cents.unwrap_or(0).max(0);
192 format!("{}.{:02}", min / 100, min % 100)
193 }
194
195 /// Bare "X.YY" dollars string for use as a numeric `<input>` value.
196 pub fn effective_price_input_value(&self) -> String {
197 let cents = self.effective_price_cents().max(0);
198 format!("{}.{:02}", cents / 100, cents % 100)
199 }
200
201 /// "$X.YY" display string. Always shows decimals; caller branches on `is_free()`.
202 pub fn effective_price_display(&self) -> String {
203 crate::formatting::format_revenue(self.effective_price_cents() as i64)
204 }
205 }
206
207 /// Get all cart items for a user with joined item, project, and seller data.
208 /// Only returns items that are still public and not deleted.
209 #[tracing::instrument(skip_all)]
210 pub async fn get_cart_items(pool: &PgPool, user_id: UserId) -> Result<Vec<CartItem>> {
211 let items = sqlx::query_as::<_, CartItem>(
212 r#"
213 SELECT c.item_id, i.project_id, i.title, i.item_type::TEXT as item_type,
214 i.price_cents, i.pwyw_enabled, i.pwyw_min_cents,
215 c.amount_cents,
216 u.username AS creator_username, p.user_id AS seller_id,
217 u.stripe_account_id AS seller_stripe_account_id,
218 u.stripe_charges_enabled AS seller_charges_enabled,
219 p.slug AS project_slug,
220 c.created_at AS added_at,
221 i.enable_license_keys,
222 i.default_max_activations
223 FROM cart_items c
224 JOIN items i ON i.id = c.item_id
225 JOIN projects p ON p.id = i.project_id
226 JOIN users u ON u.id = p.user_id
227 WHERE c.user_id = $1
228 AND i.is_public = true
229 AND i.listed = true
230 AND i.deleted_at IS NULL
231 ORDER BY u.username, c.created_at DESC
232 "#,
233 )
234 .bind(user_id)
235 .fetch_all(pool)
236 .await?;
237
238 Ok(items)
239 }
240
241 /// Get cart items for a user filtered to a specific seller.
242 #[tracing::instrument(skip_all)]
243 pub async fn get_cart_items_for_seller(
244 pool: &PgPool,
245 user_id: UserId,
246 seller_id: UserId,
247 ) -> Result<Vec<CartItem>> {
248 let items = sqlx::query_as::<_, CartItem>(
249 r#"
250 SELECT c.item_id, i.project_id, i.title, i.item_type::TEXT as item_type,
251 i.price_cents, i.pwyw_enabled, i.pwyw_min_cents,
252 c.amount_cents,
253 u.username AS creator_username, p.user_id AS seller_id,
254 u.stripe_account_id AS seller_stripe_account_id,
255 u.stripe_charges_enabled AS seller_charges_enabled,
256 p.slug AS project_slug,
257 c.created_at AS added_at,
258 i.enable_license_keys,
259 i.default_max_activations
260 FROM cart_items c
261 JOIN items i ON i.id = c.item_id
262 JOIN projects p ON p.id = i.project_id
263 JOIN users u ON u.id = p.user_id
264 WHERE c.user_id = $1
265 AND p.user_id = $2
266 AND i.is_public = true
267 AND i.listed = true
268 AND i.deleted_at IS NULL
269 ORDER BY c.created_at DESC
270 "#,
271 )
272 .bind(user_id)
273 .bind(seller_id)
274 .fetch_all(pool)
275 .await?;
276
277 Ok(items)
278 }
279
280 /// Remove all cart items belonging to a specific seller (after checkout).
281 #[tracing::instrument(skip_all)]
282 pub async fn remove_seller_items_from_cart(
283 pool: &PgPool,
284 user_id: UserId,
285 seller_id: UserId,
286 ) -> Result<u64> {
287 let result = sqlx::query(
288 r#"
289 DELETE FROM cart_items c
290 USING items i
291 JOIN projects p ON p.id = i.project_id
292 WHERE c.user_id = $1
293 AND c.item_id = i.id
294 AND p.user_id = $2
295 "#,
296 )
297 .bind(user_id)
298 .bind(seller_id)
299 .execute(pool)
300 .await?;
301
302 Ok(result.rows_affected())
303 }
304
305 /// Remove stale cart items older than the given duration.
306 #[tracing::instrument(skip_all)]
307 pub async fn cleanup_stale_cart_items(pool: &PgPool, older_than: Duration) -> Result<u64> {
308 let cutoff = Utc::now() - older_than;
309 let result = sqlx::query("DELETE FROM cart_items WHERE created_at < $1")
310 .bind(cutoff)
311 .execute(pool)
312 .await?;
313
314 Ok(result.rows_affected())
315 }
316
317 /// Remove cart items for items that are no longer available (unpublished, deleted).
318 #[tracing::instrument(skip_all)]
319 pub async fn cleanup_unavailable_cart_items(pool: &PgPool) -> Result<u64> {
320 let result = sqlx::query(
321 r#"
322 DELETE FROM cart_items c
323 USING items i
324 WHERE c.item_id = i.id
325 AND (i.is_public = false OR i.listed = false OR i.deleted_at IS NOT NULL)
326 "#,
327 )
328 .execute(pool)
329 .await?;
330
331 Ok(result.rows_affected())
332 }
333
334 #[cfg(test)]
335 mod tests {
336 use super::*;
337 use chrono::Utc;
338
339 fn make_cart_item(
340 price_cents: i32,
341 pwyw_enabled: bool,
342 pwyw_min_cents: Option<i32>,
343 amount_cents: Option<i32>,
344 ) -> CartItem {
345 CartItem {
346 item_id: ItemId::nil(),
347 project_id: ProjectId::nil(),
348 title: String::new(),
349 item_type: String::from("audio"),
350 price_cents,
351 pwyw_enabled,
352 pwyw_min_cents,
353 amount_cents,
354 creator_username: String::new(),
355 seller_id: UserId::nil(),
356 seller_stripe_account_id: None,
357 seller_charges_enabled: false,
358 project_slug: String::new(),
359 added_at: Utc::now(),
360 enable_license_keys: false,
361 default_max_activations: None,
362 }
363 }
364
365 // ---- effective_price_cents ----
366
367 #[test]
368 fn fixed_price_returns_price_cents() {
369 let item = make_cart_item(500, false, None, None);
370 assert_eq!(item.effective_price_cents(), 500);
371 }
372
373 #[test]
374 fn pwyw_amount_above_min_returns_amount() {
375 let item = make_cart_item(0, true, Some(300), Some(500));
376 assert_eq!(item.effective_price_cents(), 500);
377 }
378
379 #[test]
380 fn pwyw_amount_below_min_clamps_to_min() {
381 let item = make_cart_item(0, true, Some(500), Some(200));
382 assert_eq!(item.effective_price_cents(), 500);
383 }
384
385 #[test]
386 fn pwyw_no_amount_uses_min() {
387 let item = make_cart_item(0, true, Some(400), None);
388 assert_eq!(item.effective_price_cents(), 400);
389 }
390
391 #[test]
392 fn pwyw_no_min_defaults_to_zero() {
393 let item = make_cart_item(0, true, None, Some(700));
394 assert_eq!(item.effective_price_cents(), 700);
395 }
396
397 #[test]
398 fn pwyw_negative_amount_clamps_to_zero() {
399 let item = make_cart_item(0, true, Some(0), Some(-100));
400 assert_eq!(item.effective_price_cents(), 0);
401 }
402
403 #[test]
404 fn pwyw_both_none_returns_zero() {
405 let item = make_cart_item(0, true, None, None);
406 assert_eq!(item.effective_price_cents(), 0);
407 }
408
409 // ---- is_free ----
410
411 #[test]
412 fn fixed_price_zero_is_free() {
413 let item = make_cart_item(0, false, None, None);
414 assert!(item.is_free());
415 }
416
417 #[test]
418 fn fixed_price_one_is_not_free() {
419 let item = make_cart_item(1, false, None, None);
420 assert!(!item.is_free());
421 }
422
423 #[test]
424 fn pwyw_zero_min_none_amount_is_free() {
425 let item = make_cart_item(0, true, Some(0), None);
426 assert!(item.is_free());
427 }
428
429 // ---- pwyw_min_dollars ----
430
431 #[test]
432 fn pwyw_min_dollars_zero() {
433 let item = make_cart_item(0, true, Some(0), None);
434 assert_eq!(item.pwyw_min_dollars(), "0.00");
435 }
436
437 #[test]
438 fn pwyw_min_dollars_one_dollar() {
439 let item = make_cart_item(0, true, Some(100), None);
440 assert_eq!(item.pwyw_min_dollars(), "1.00");
441 }
442
443 #[test]
444 fn pwyw_min_dollars_one_fifty() {
445 let item = make_cart_item(0, true, Some(150), None);
446 assert_eq!(item.pwyw_min_dollars(), "1.50");
447 }
448
449 #[test]
450 fn pwyw_min_dollars_ninety_nine_cents() {
451 let item = make_cart_item(0, true, Some(99), None);
452 assert_eq!(item.pwyw_min_dollars(), "0.99");
453 }
454
455 #[test]
456 fn pwyw_min_dollars_none_returns_zero() {
457 let item = make_cart_item(0, true, None, None);
458 assert_eq!(item.pwyw_min_dollars(), "0.00");
459 }
460 }
461