1use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::sync::RwLock;
27use uuid::Uuid;
28use wasm_bindgen::prelude::*;
29
30static SESSIONS: RwLock<Option<HashMap<String, CheckoutSessionInternal>>> = RwLock::new(None);
32
33fn get_sessions() -> &'static RwLock<Option<HashMap<String, CheckoutSessionInternal>>> {
34 &SESSIONS
35}
36
37fn with_sessions<F, R>(f: F) -> Result<R, JsValue>
38where
39 F: FnOnce(&mut HashMap<String, CheckoutSessionInternal>) -> Result<R, String>,
40{
41 let mut guard = get_sessions()
42 .write()
43 .map_err(|_| JsValue::from_str("Lock error"))?;
44
45 if guard.is_none() {
46 *guard = Some(HashMap::new());
47 }
48
49 let sessions = guard.as_mut().unwrap();
50 f(sessions).map_err(|e| JsValue::from_str(&e))
51}
52
53#[derive(Clone, Serialize, Deserialize)]
55struct MoneyInternal {
56 amount: i64,
57 currency: String,
58}
59
60#[derive(Clone, Serialize, Deserialize)]
61struct LineItemInternal {
62 id: String,
63 title: String,
64 quantity: i32,
65 unit_price: MoneyInternal,
66 variant_id: Option<String>,
67 sku: Option<String>,
68 image_url: Option<String>,
69}
70
71#[derive(Clone, Serialize, Deserialize)]
72struct TotalsInternal {
73 subtotal: MoneyInternal,
74 tax: MoneyInternal,
75 shipping: MoneyInternal,
76 discount: MoneyInternal,
77 grand_total: MoneyInternal,
78}
79
80#[derive(Clone, Serialize, Deserialize)]
81struct CustomerInternal {
82 billing_address: Option<AddressInternal>,
83 shipping_address: Option<AddressInternal>,
84}
85
86#[derive(Clone, Serialize, Deserialize)]
87struct AddressInternal {
88 name: Option<String>,
89 line1: Option<String>,
90 line2: Option<String>,
91 city: Option<String>,
92 region: Option<String>,
93 postal_code: Option<String>,
94 country: Option<String>,
95 phone: Option<String>,
96 email: Option<String>,
97}
98
99#[derive(Clone, Serialize, Deserialize)]
100struct CheckoutSessionInternal {
101 id: String,
102 status: String,
103 items: Vec<LineItemInternal>,
104 totals: TotalsInternal,
105 customer: Option<CustomerInternal>,
106 created_at: String,
107 updated_at: String,
108}
109
110#[derive(Clone, Serialize, Deserialize)]
111struct OrderInternal {
112 id: String,
113 checkout_session_id: String,
114 status: String,
115 permalink_url: Option<String>,
116}
117
118fn get_product(id: &str) -> Option<LineItemInternal> {
120 match id {
121 "prod_laptop_001" => Some(LineItemInternal {
122 id: id.to_string(),
123 title: "MacBook Pro 14\"".to_string(),
124 quantity: 1,
125 unit_price: MoneyInternal {
126 amount: 199900,
127 currency: "USD".to_string(),
128 },
129 variant_id: Some("var_001".to_string()),
130 sku: Some("MBP14-M3".to_string()),
131 image_url: Some("https://example.com/mbp14.jpg".to_string()),
132 }),
133 "prod_mouse_002" => Some(LineItemInternal {
134 id: id.to_string(),
135 title: "Magic Mouse".to_string(),
136 quantity: 1,
137 unit_price: MoneyInternal {
138 amount: 9900,
139 currency: "USD".to_string(),
140 },
141 variant_id: Some("var_002".to_string()),
142 sku: Some("MM-WHITE".to_string()),
143 image_url: Some("https://example.com/mouse.jpg".to_string()),
144 }),
145 "prod_keyboard_003" => Some(LineItemInternal {
146 id: id.to_string(),
147 title: "Magic Keyboard".to_string(),
148 quantity: 1,
149 unit_price: MoneyInternal {
150 amount: 29900,
151 currency: "USD".to_string(),
152 },
153 variant_id: Some("var_003".to_string()),
154 sku: Some("MK-SILVER".to_string()),
155 image_url: Some("https://example.com/keyboard.jpg".to_string()),
156 }),
157 _ => None,
158 }
159}
160
161fn calculate_totals(items: &[LineItemInternal]) -> TotalsInternal {
162 let subtotal: i64 = items
163 .iter()
164 .map(|i| i.unit_price.amount * i.quantity as i64)
165 .sum();
166 let tax = (subtotal as f64 * 0.0875) as i64;
167 let shipping = if subtotal > 10000 { 0 } else { 999 };
168 let grand_total = subtotal + tax + shipping;
169
170 TotalsInternal {
171 subtotal: MoneyInternal {
172 amount: subtotal,
173 currency: "USD".to_string(),
174 },
175 tax: MoneyInternal {
176 amount: tax,
177 currency: "USD".to_string(),
178 },
179 shipping: MoneyInternal {
180 amount: shipping,
181 currency: "USD".to_string(),
182 },
183 discount: MoneyInternal {
184 amount: 0,
185 currency: "USD".to_string(),
186 },
187 grand_total: MoneyInternal {
188 amount: grand_total,
189 currency: "USD".to_string(),
190 },
191 }
192}
193
194fn now_iso() -> String {
195 chrono::Utc::now().to_rfc3339()
196}
197
198#[wasm_bindgen]
200pub struct AcpClient {
201 api_key: Option<String>,
202}
203
204#[wasm_bindgen]
205impl AcpClient {
206 #[wasm_bindgen(constructor)]
208 pub fn new(api_key: Option<String>) -> AcpClient {
209 AcpClient { api_key }
210 }
211
212 #[wasm_bindgen(js_name = createCheckoutSession)]
217 pub fn create_checkout_session(&self, items: JsValue) -> Result<JsValue, JsValue> {
218 #[derive(Deserialize)]
219 struct RequestItem {
220 id: String,
221 quantity: i32,
222 }
223
224 let items: Vec<RequestItem> = serde_wasm_bindgen::from_value(items)?;
225
226 if items.is_empty() {
227 return Err(JsValue::from_str("At least one item is required"));
228 }
229
230 let mut line_items = Vec::new();
231 for item in items {
232 if let Some(mut product) = get_product(&item.id) {
233 product.quantity = item.quantity;
234 line_items.push(product);
235 } else {
236 return Err(JsValue::from_str(&format!(
237 "Product not found: {}",
238 item.id
239 )));
240 }
241 }
242
243 let now = now_iso();
244 let totals = calculate_totals(&line_items);
245
246 let session = CheckoutSessionInternal {
247 id: Uuid::new_v4().to_string(),
248 status: "not_ready_for_payment".to_string(),
249 items: line_items,
250 totals,
251 customer: None,
252 created_at: now.clone(),
253 updated_at: now,
254 };
255
256 with_sessions(|sessions| {
257 sessions.insert(session.id.clone(), session.clone());
258 Ok(())
259 })?;
260
261 serde_wasm_bindgen::to_value(&session).map_err(|e| JsValue::from_str(&e.to_string()))
262 }
263
264 #[wasm_bindgen(js_name = getCheckoutSession)]
266 pub fn get_checkout_session(&self, session_id: &str) -> Result<JsValue, JsValue> {
267 with_sessions(|sessions| {
268 sessions
269 .get(session_id)
270 .cloned()
271 .ok_or_else(|| format!("Session not found: {}", session_id))
272 })
273 .and_then(|session| {
274 serde_wasm_bindgen::to_value(&session).map_err(|e| JsValue::from_str(&e.to_string()))
275 })
276 }
277
278 #[wasm_bindgen(js_name = updateCheckoutSession)]
280 pub fn update_checkout_session(
281 &self,
282 session_id: &str,
283 items: Option<JsValue>,
284 customer: Option<JsValue>,
285 ) -> Result<JsValue, JsValue> {
286 #[derive(Deserialize)]
287 struct RequestItem {
288 id: String,
289 quantity: i32,
290 }
291
292 with_sessions(|sessions| {
293 let session = sessions
294 .get_mut(session_id)
295 .ok_or_else(|| format!("Session not found: {}", session_id))?;
296
297 if session.status == "completed" || session.status == "canceled" {
298 return Err("Cannot update completed or canceled session".to_string());
299 }
300
301 if let Some(items_val) = items {
303 if !items_val.is_undefined() && !items_val.is_null() {
304 let items: Vec<RequestItem> = serde_wasm_bindgen::from_value(items_val)
305 .map_err(|e| e.to_string())?;
306
307 let mut line_items = Vec::new();
308 for item in items {
309 if let Some(mut product) = get_product(&item.id) {
310 product.quantity = item.quantity;
311 line_items.push(product);
312 } else {
313 return Err(format!("Product not found: {}", item.id));
314 }
315 }
316 session.items = line_items;
317 session.totals = calculate_totals(&session.items);
318 }
319 }
320
321 if let Some(customer_val) = customer {
323 if !customer_val.is_undefined() && !customer_val.is_null() {
324 session.customer = serde_wasm_bindgen::from_value(customer_val)
325 .map_err(|e| e.to_string())?;
326 }
327 }
328
329 session.status = "ready_for_payment".to_string();
330 session.updated_at = now_iso();
331
332 Ok(session.clone())
333 })
334 .and_then(|session| {
335 serde_wasm_bindgen::to_value(&session).map_err(|e| JsValue::from_str(&e.to_string()))
336 })
337 }
338
339 #[wasm_bindgen(js_name = completeCheckoutSession)]
341 pub fn complete_checkout_session(
342 &self,
343 session_id: &str,
344 payment_token: &str,
345 ) -> Result<JsValue, JsValue> {
346 #[derive(Serialize)]
347 struct CompletionResult {
348 session: CheckoutSessionInternal,
349 order: OrderInternal,
350 }
351
352 with_sessions(|sessions| {
353 let session = sessions
354 .get_mut(session_id)
355 .ok_or_else(|| format!("Session not found: {}", session_id))?;
356
357 if session.status == "completed" {
358 return Err("Session already completed".to_string());
359 }
360
361 if session.status == "canceled" {
362 return Err("Cannot complete canceled session".to_string());
363 }
364
365 session.status = "completed".to_string();
366 session.updated_at = now_iso();
367
368 let order = OrderInternal {
369 id: Uuid::new_v4().to_string(),
370 checkout_session_id: session.id.clone(),
371 status: "placed".to_string(),
372 permalink_url: Some(format!("https://orders.example.com/{}", session.id)),
373 };
374
375 Ok(CompletionResult {
376 session: session.clone(),
377 order,
378 })
379 })
380 .and_then(|result| {
381 serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
382 })
383 }
384
385 #[wasm_bindgen(js_name = cancelCheckoutSession)]
387 pub fn cancel_checkout_session(&self, session_id: &str) -> Result<JsValue, JsValue> {
388 with_sessions(|sessions| {
389 let session = sessions
390 .get_mut(session_id)
391 .ok_or_else(|| format!("Session not found: {}", session_id))?;
392
393 if session.status == "completed" {
394 return Err("Cannot cancel completed session".to_string());
395 }
396
397 session.status = "canceled".to_string();
398 session.updated_at = now_iso();
399
400 Ok(session.clone())
401 })
402 .and_then(|session| {
403 serde_wasm_bindgen::to_value(&session).map_err(|e| JsValue::from_str(&e.to_string()))
404 })
405 }
406}
407
408#[wasm_bindgen]
410pub fn version() -> String {
411 "1.0.0".to_string()
412}
413
414#[wasm_bindgen(start)]
416pub fn init() {
417 #[cfg(feature = "console_error_panic_hook")]
419 console_error_panic_hook::set_once();
420}