Skip to main content

max / makenotwork

4.5 KB · 146 lines History Blame Raw
1 //! Cart API: add/remove items, get count.
2
3 use axum::Json;
4 use axum::extract::{Path, State};
5 use axum::response::IntoResponse;
6
7 use sqlx::PgPool;
8
9 use crate::{
10 auth::AuthUser,
11 db::{self, ItemId},
12 error::{AppError, Result},
13 };
14
15 /// Toggle an item's cart status. Returns the new state.
16 #[tracing::instrument(skip_all, name = "cart::toggle")]
17 pub(super) async fn toggle_cart(
18 State(db): State<PgPool>,
19 AuthUser(user): AuthUser,
20 Path(item_id): Path<ItemId>,
21 ) -> Result<impl IntoResponse> {
22 // Single-query pre-flight: item existence, visibility, ownership, purchase, cart status
23 let pf = db::cart::toggle_cart_preflight(&db, user.id, item_id)
24 .await?
25 .ok_or(AppError::NotFound)?;
26
27 if !pf.is_public {
28 return Err(AppError::NotFound);
29 }
30 if !pf.listed {
31 // Unlisted items are bundle-only, `item.rs:47-49` enforces this on the
32 // single-item checkout path; the cart flow must enforce the same gate
33 // or the bundle-only restriction is bypassable by any UUID guesser.
34 return Err(AppError::BadRequest(
35 "This item is only available through its bundle.".to_string(),
36 ));
37 }
38 if pf.is_owner {
39 return Err(AppError::BadRequest(
40 "You can't add your own items to your cart.".to_string(),
41 ));
42 }
43 if pf.has_purchased {
44 return Err(AppError::BadRequest(
45 "You already own this item.".to_string(),
46 ));
47 }
48
49 if pf.in_cart {
50 db::cart::remove_from_cart(&db, user.id, item_id).await?;
51 } else {
52 db::cart::add_to_cart(&db, user.id, item_id).await?;
53 }
54
55 Ok(Json(serde_json::json!({ "in_cart": !pf.in_cart })))
56 }
57
58 /// Remove an item from the cart explicitly. Returns 204.
59 #[tracing::instrument(skip_all, name = "cart::remove")]
60 pub(super) async fn remove_from_cart(
61 State(db): State<PgPool>,
62 AuthUser(user): AuthUser,
63 Path(item_id): Path<ItemId>,
64 ) -> Result<impl IntoResponse> {
65 db::cart::remove_from_cart(&db, user.id, item_id).await?;
66 Ok(axum::http::StatusCode::NO_CONTENT)
67 }
68
69 /// Update the PWYW amount for a cart item.
70 #[tracing::instrument(skip_all, name = "cart::update_amount")]
71 pub(super) async fn update_cart_amount(
72 State(db): State<PgPool>,
73 AuthUser(user): AuthUser,
74 Path(item_id): Path<ItemId>,
75 Json(body): Json<UpdateCartAmountRequest>,
76 ) -> Result<impl IntoResponse> {
77 // Verify item exists and is PWYW
78 let item = db::items::get_item_by_id(&db, item_id)
79 .await?
80 .ok_or(AppError::NotFound)?;
81
82 if !item.pwyw_enabled {
83 return Err(AppError::BadRequest(
84 "This item does not use pay-what-you-want pricing.".to_string(),
85 ));
86 }
87
88 // Validate amount against minimum
89 let min = item.pwyw_min_cents.unwrap_or(0);
90 if body.amount_cents < min {
91 // The floor is denominated in the seller's currency, so the message has
92 // to name theirs and not the buyer's. Only looked up on the reject path.
93 let seller_currency = db::projects::get_project_by_id(&db, item.project_id)
94 .await
95 .ok()
96 .flatten()
97 .map(|p| p.user_id);
98 let seller_currency = match seller_currency {
99 Some(uid) => db::users::get_user_by_id(&db, uid)
100 .await
101 .ok()
102 .flatten()
103 .map(|u| u.settlement_currency)
104 .unwrap_or_default(),
105 None => crate::currency::SettlementCurrency::default(),
106 };
107 return Err(AppError::BadRequest(format!(
108 "Amount must be at least {}.",
109 crate::formatting::format_revenue(i64::from(min), seller_currency)
110 )));
111 }
112
113 // Cap at $10,000
114 if body.amount_cents > 1_000_000 {
115 return Err(AppError::BadRequest(
116 "Amount cannot exceed $10,000.".to_string(),
117 ));
118 }
119
120 let updated =
121 db::cart::update_cart_amount(&db, user.id, item_id, Some(body.amount_cents)).await?;
122
123 if !updated {
124 return Err(AppError::NotFound);
125 }
126
127 Ok(Json(
128 serde_json::json!({ "amount_cents": body.amount_cents }),
129 ))
130 }
131
132 #[derive(Debug, serde::Deserialize)]
133 pub(super) struct UpdateCartAmountRequest {
134 pub amount_cents: i32,
135 }
136
137 /// Get the number of items in the cart (for nav badge).
138 #[tracing::instrument(skip_all, name = "cart::count")]
139 pub(super) async fn cart_count(
140 State(db): State<PgPool>,
141 AuthUser(user): AuthUser,
142 ) -> Result<impl IntoResponse> {
143 let count = db::cart::get_cart_count(&db, user.id).await?;
144 Ok(Json(serde_json::json!({ "count": count })))
145 }
146