Skip to main content

max / makenotwork

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