acp_wasm/
lib.rs

1//! WebAssembly bindings for StateSet ACP Handler
2//!
3//! This crate provides browser-compatible WASM bindings for the ACP checkout service.
4//!
5//! # Example (JavaScript)
6//!
7//! ```javascript
8//! import init, { AcpClient } from '@stateset/acp-wasm';
9//!
10//! await init();
11//!
12//! const client = new AcpClient('api_key_demo_123');
13//!
14//! const session = await client.createCheckoutSession([
15//!     { id: 'prod_laptop_001', quantity: 1 }
16//! ]);
17//!
18//! console.log('Session ID:', session.id);
19//!
20//! const result = await client.completeCheckoutSession(session.id, 'tok_demo');
21//! console.log('Order ID:', result.order.id);
22//! ```
23
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::sync::RwLock;
27use uuid::Uuid;
28use wasm_bindgen::prelude::*;
29
30// Global session store
31static 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// Internal types
54#[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
118// Product catalog
119fn 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/// ACP Client for browser environments.
199#[wasm_bindgen]
200pub struct AcpClient {
201    api_key: Option<String>,
202}
203
204#[wasm_bindgen]
205impl AcpClient {
206    /// Creates a new ACP client.
207    #[wasm_bindgen(constructor)]
208    pub fn new(api_key: Option<String>) -> AcpClient {
209        AcpClient { api_key }
210    }
211
212    /// Creates a new checkout session.
213    ///
214    /// @param items - Array of items with id and quantity
215    /// @returns Promise resolving to the created checkout session
216    #[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    /// Gets an existing checkout session.
265    #[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    /// Updates a checkout session.
279    #[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            // Update items if provided
302            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            // Update customer if provided
322            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    /// Completes a checkout session with payment.
340    #[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    /// Cancels a checkout session.
386    #[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/// Get library version.
409#[wasm_bindgen]
410pub fn version() -> String {
411    "1.0.0".to_string()
412}
413
414/// Initialize the WASM module (call once at startup).
415#[wasm_bindgen(start)]
416pub fn init() {
417    // Initialize panic hook for better error messages
418    #[cfg(feature = "console_error_panic_hook")]
419    console_error_panic_hook::set_once();
420}