Skip to main content

max / makenotwork

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