Skip to main content

stateset_http/
dto.rs

1//! Data Transfer Objects for HTTP request/response bodies.
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use stateset_primitives::{CustomerId, OrderId, ProductId, ReturnId};
7use utoipa::{IntoParams, ToSchema};
8use uuid::Uuid;
9
10// ============================================================================
11// Pagination
12// ============================================================================
13
14/// Query parameters for paginated list endpoints.
15#[derive(Debug, Clone, Deserialize, Serialize, Default, IntoParams)]
16#[into_params(parameter_in = Query)]
17pub struct PaginationParams {
18    /// Maximum number of results to return (default: 50).
19    pub limit: Option<u32>,
20    /// Number of results to skip (default: 0).
21    pub offset: Option<u32>,
22}
23
24impl PaginationParams {
25    /// Default page size.
26    pub const DEFAULT_LIMIT: u32 = 50;
27    /// Maximum allowed page size.
28    pub const MAX_LIMIT: u32 = 200;
29
30    /// Resolved limit with bounds checking.
31    #[must_use]
32    pub fn resolved_limit(&self) -> u32 {
33        resolve_limit(self.limit)
34    }
35
36    /// Resolved offset.
37    #[must_use]
38    pub fn resolved_offset(&self) -> u32 {
39        self.offset.unwrap_or(0)
40    }
41}
42
43#[must_use]
44fn resolve_limit(limit: Option<u32>) -> u32 {
45    limit.unwrap_or(PaginationParams::DEFAULT_LIMIT).clamp(1, PaginationParams::MAX_LIMIT)
46}
47
48/// Request one extra row to detect whether another page exists.
49#[must_use]
50pub const fn overfetch_limit(limit: u32) -> u32 {
51    limit.saturating_add(1)
52}
53
54/// Trim an overfetched page back to the requested size and return `has_more`.
55pub fn finalize_page<T>(items: &mut Vec<T>, requested_limit: u32) -> bool {
56    let requested_limit = requested_limit as usize;
57    let has_more = items.len() > requested_limit;
58    if has_more {
59        items.truncate(requested_limit);
60    }
61    has_more
62}
63
64// ============================================================================
65// Cursor helpers
66// ============================================================================
67
68use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
69
70/// Encode a keyset cursor from `(sort_key, id)`.
71#[must_use]
72pub fn encode_cursor(sort_key: &str, id: &str) -> String {
73    let payload = format!("{sort_key}\x00{id}");
74    URL_SAFE_NO_PAD.encode(payload.as_bytes())
75}
76
77/// Decode a keyset cursor into `(sort_key, id)`.
78#[must_use]
79pub fn decode_cursor(cursor: &str) -> Option<(String, String)> {
80    let bytes = URL_SAFE_NO_PAD.decode(cursor).ok()?;
81    let s = String::from_utf8(bytes).ok()?;
82    let (sort_key, id) = s.split_once('\x00')?;
83    Some((sort_key.to_string(), id.to_string()))
84}
85
86// ============================================================================
87// Filter query parameters
88// ============================================================================
89
90/// Query parameters for `GET /api/v1/orders` with filtering.
91#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
92#[into_params(parameter_in = Query)]
93pub struct OrderFilterParams {
94    /// Maximum number of results to return (default: 50).
95    pub limit: Option<u32>,
96    /// Number of results to skip (default: 0). Ignored when `after` cursor is set.
97    pub offset: Option<u32>,
98    /// Cursor for keyset pagination (opaque token from `next_cursor`).
99    pub after: Option<String>,
100    /// Filter by customer ID (UUID).
101    pub customer_id: Option<String>,
102    /// Filter by order status (pending, confirmed, shipped, delivered, cancelled).
103    pub status: Option<String>,
104    /// Filter by payment status (pending, paid, `partially_refunded`, refunded).
105    pub payment_status: Option<String>,
106    /// Filter by fulfillment status (unfulfilled, partial, fulfilled).
107    pub fulfillment_status: Option<String>,
108    /// Orders created on or after this date (RFC 3339, e.g. 2024-01-15T00:00:00Z).
109    pub from_date: Option<String>,
110    /// Orders created on or before this date (RFC 3339, e.g. 2024-12-31T23:59:59Z).
111    pub to_date: Option<String>,
112}
113
114impl OrderFilterParams {
115    /// Resolved limit with bounds checking.
116    #[must_use]
117    pub fn resolved_limit(&self) -> u32 {
118        resolve_limit(self.limit)
119    }
120
121    /// Resolved offset.
122    #[must_use]
123    pub fn resolved_offset(&self) -> u32 {
124        self.offset.unwrap_or(0)
125    }
126}
127
128/// Query parameters for `GET /api/v1/customers` with filtering.
129#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
130#[into_params(parameter_in = Query)]
131pub struct CustomerFilterParams {
132    /// Maximum number of results to return (default: 50).
133    pub limit: Option<u32>,
134    /// Number of results to skip (default: 0). Ignored when `after` cursor is set.
135    pub offset: Option<u32>,
136    /// Cursor for keyset pagination (opaque token from `next_cursor`).
137    pub after: Option<String>,
138    /// Filter by email address (exact match).
139    pub email: Option<String>,
140    /// Filter by customer status (active, inactive, deleted).
141    pub status: Option<String>,
142    /// Filter by tag.
143    pub tag: Option<String>,
144    /// Filter by marketing opt-in.
145    pub accepts_marketing: Option<bool>,
146}
147
148impl CustomerFilterParams {
149    /// Resolved limit with bounds checking.
150    #[must_use]
151    pub fn resolved_limit(&self) -> u32 {
152        resolve_limit(self.limit)
153    }
154
155    /// Resolved offset.
156    #[must_use]
157    pub fn resolved_offset(&self) -> u32 {
158        self.offset.unwrap_or(0)
159    }
160}
161
162/// Query parameters for `GET /api/v1/products` with filtering.
163#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
164#[into_params(parameter_in = Query)]
165pub struct ProductFilterParams {
166    /// Maximum number of results to return (default: 50).
167    pub limit: Option<u32>,
168    /// Number of results to skip (default: 0). Ignored when `after` cursor is set.
169    pub offset: Option<u32>,
170    /// Cursor for keyset pagination (opaque token from `next_cursor`).
171    pub after: Option<String>,
172    /// Filter by product status (draft, active, archived).
173    pub status: Option<String>,
174    /// Filter by product type (simple, digital, bundle, subscription, service).
175    pub product_type: Option<String>,
176    /// Full-text search across name and description.
177    pub search: Option<String>,
178    /// Filter by category.
179    pub category: Option<String>,
180    /// Minimum price filter.
181    pub min_price: Option<String>,
182    /// Maximum price filter.
183    pub max_price: Option<String>,
184    /// Filter by stock availability.
185    pub in_stock: Option<bool>,
186}
187
188impl ProductFilterParams {
189    /// Resolved limit with bounds checking.
190    #[must_use]
191    pub fn resolved_limit(&self) -> u32 {
192        resolve_limit(self.limit)
193    }
194
195    /// Resolved offset.
196    #[must_use]
197    pub fn resolved_offset(&self) -> u32 {
198        self.offset.unwrap_or(0)
199    }
200}
201
202/// Query parameters for `GET /api/v1/returns` with filtering.
203#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
204#[into_params(parameter_in = Query)]
205pub struct ReturnFilterParams {
206    /// Maximum number of results to return (default: 50).
207    pub limit: Option<u32>,
208    /// Number of results to skip (default: 0). Ignored when `after` cursor is set.
209    pub offset: Option<u32>,
210    /// Cursor for keyset pagination (opaque token from `next_cursor`).
211    pub after: Option<String>,
212    /// Filter by order ID (UUID).
213    pub order_id: Option<String>,
214    /// Filter by customer ID (UUID).
215    pub customer_id: Option<String>,
216    /// Filter by return status (requested, approved, rejected, received, refunded, closed).
217    pub status: Option<String>,
218    /// Filter by return reason (defective, `wrong_item`, `not_as_described`, `changed_mind`, other).
219    pub reason: Option<String>,
220    /// Returns created on or after this date (RFC 3339).
221    pub from_date: Option<String>,
222    /// Returns created on or before this date (RFC 3339).
223    pub to_date: Option<String>,
224}
225
226impl ReturnFilterParams {
227    /// Resolved limit with bounds checking.
228    #[must_use]
229    pub fn resolved_limit(&self) -> u32 {
230        resolve_limit(self.limit)
231    }
232
233    /// Resolved offset.
234    #[must_use]
235    pub fn resolved_offset(&self) -> u32 {
236        self.offset.unwrap_or(0)
237    }
238}
239
240// ============================================================================
241// Orders
242// ============================================================================
243
244/// Request body for `POST /api/v1/orders`.
245#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
246pub struct CreateOrderRequest {
247    /// The customer placing the order.
248    #[schema(value_type = String, format = "uuid")]
249    pub customer_id: CustomerId,
250    /// Line items for the order.
251    pub items: Vec<CreateOrderItemRequest>,
252    /// ISO currency code (default: USD).
253    pub currency: Option<String>,
254    /// Shipping address.
255    pub shipping_address: Option<AddressDto>,
256    /// Billing address.
257    pub billing_address: Option<AddressDto>,
258    /// Optional notes.
259    pub notes: Option<String>,
260    /// Payment method identifier.
261    pub payment_method: Option<String>,
262    /// Shipping method identifier.
263    pub shipping_method: Option<String>,
264}
265
266/// A single line item in a create-order request.
267#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
268pub struct CreateOrderItemRequest {
269    #[schema(value_type = String, format = "uuid")]
270    pub product_id: ProductId,
271    pub variant_id: Option<Uuid>,
272    pub sku: String,
273    pub name: String,
274    pub quantity: i32,
275    #[schema(value_type = String)]
276    pub unit_price: Decimal,
277    #[schema(value_type = Option<String>)]
278    pub discount: Option<Decimal>,
279    #[schema(value_type = Option<String>)]
280    pub tax_amount: Option<Decimal>,
281}
282
283/// Address DTO shared by orders and customers.
284#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
285pub struct AddressDto {
286    pub line1: String,
287    pub line2: Option<String>,
288    pub city: String,
289    pub state: Option<String>,
290    pub postal_code: String,
291    pub country: String,
292}
293
294/// Response body for a single order.
295#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
296pub struct OrderResponse {
297    #[schema(value_type = String, format = "uuid")]
298    pub id: OrderId,
299    pub order_number: String,
300    #[schema(value_type = String, format = "uuid")]
301    pub customer_id: CustomerId,
302    pub status: String,
303    #[schema(value_type = String)]
304    pub total_amount: Decimal,
305    pub currency: String,
306    pub payment_status: String,
307    pub fulfillment_status: String,
308    pub items: Vec<OrderItemResponse>,
309    pub created_at: DateTime<Utc>,
310    pub updated_at: DateTime<Utc>,
311}
312
313/// A single line item in an order response.
314#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
315pub struct OrderItemResponse {
316    pub id: Uuid,
317    #[schema(value_type = String, format = "uuid")]
318    pub product_id: ProductId,
319    pub sku: String,
320    pub name: String,
321    pub quantity: i32,
322    #[schema(value_type = String)]
323    pub unit_price: Decimal,
324    #[schema(value_type = String)]
325    pub total: Decimal,
326}
327
328/// Response body for `GET /api/v1/orders` (list).
329#[derive(Debug, Clone, Serialize, ToSchema)]
330pub struct OrderListResponse {
331    pub orders: Vec<OrderResponse>,
332    pub total: usize,
333    pub limit: u32,
334    pub offset: u32,
335    /// Opaque cursor for fetching the next page (keyset pagination).
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub next_cursor: Option<String>,
338    /// Whether more results are available after this page.
339    pub has_more: bool,
340}
341
342// ============================================================================
343// Customers
344// ============================================================================
345
346/// Request body for `POST /api/v1/customers`.
347#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
348pub struct CreateCustomerRequest {
349    pub email: String,
350    pub first_name: String,
351    pub last_name: String,
352    pub phone: Option<String>,
353    pub accepts_marketing: Option<bool>,
354    pub tags: Option<Vec<String>>,
355    #[schema(value_type = Option<Object>)]
356    pub metadata: Option<serde_json::Value>,
357}
358
359/// Response body for a single customer.
360#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
361pub struct CustomerResponse {
362    #[schema(value_type = String, format = "uuid")]
363    pub id: CustomerId,
364    pub email: String,
365    pub first_name: String,
366    pub last_name: String,
367    pub phone: Option<String>,
368    pub status: String,
369    pub accepts_marketing: bool,
370    pub created_at: DateTime<Utc>,
371    pub updated_at: DateTime<Utc>,
372}
373
374/// Request body for `PATCH /api/v1/customers/:id`.
375#[derive(Debug, Clone, Default, Deserialize, Serialize, ToSchema)]
376pub struct UpdateCustomerRequest {
377    pub email: Option<String>,
378    pub first_name: Option<String>,
379    pub last_name: Option<String>,
380    pub phone: Option<String>,
381    pub status: Option<String>,
382    pub accepts_marketing: Option<bool>,
383    pub tags: Option<Vec<String>>,
384    #[schema(value_type = Option<Object>)]
385    pub metadata: Option<serde_json::Value>,
386}
387
388/// Response body for `GET /api/v1/customers` (list).
389#[derive(Debug, Clone, Serialize, ToSchema)]
390pub struct CustomerListResponse {
391    pub customers: Vec<CustomerResponse>,
392    pub total: usize,
393    pub limit: u32,
394    pub offset: u32,
395    /// Opaque cursor for fetching the next page (keyset pagination).
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub next_cursor: Option<String>,
398    /// Whether more results are available after this page.
399    pub has_more: bool,
400}
401
402// ============================================================================
403// Products
404// ============================================================================
405
406/// Request body for `POST /api/v1/products`.
407#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
408pub struct CreateProductRequest {
409    pub name: String,
410    pub slug: Option<String>,
411    pub description: Option<String>,
412    pub product_type: Option<String>,
413}
414
415/// Response body for a single product.
416#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
417pub struct ProductResponse {
418    #[schema(value_type = String, format = "uuid")]
419    pub id: ProductId,
420    pub name: String,
421    pub slug: String,
422    pub description: String,
423    pub status: String,
424    pub product_type: String,
425    pub created_at: DateTime<Utc>,
426    pub updated_at: DateTime<Utc>,
427}
428
429/// Request body for `PATCH /api/v1/products/:id`.
430#[derive(Debug, Clone, Default, Deserialize, Serialize, ToSchema)]
431pub struct UpdateProductRequest {
432    pub name: Option<String>,
433    pub description: Option<String>,
434    pub status: Option<String>,
435    pub product_type: Option<String>,
436}
437
438/// Response body for `GET /api/v1/products` (list).
439#[derive(Debug, Clone, Serialize, ToSchema)]
440pub struct ProductListResponse {
441    pub products: Vec<ProductResponse>,
442    pub total: usize,
443    pub limit: u32,
444    pub offset: u32,
445    /// Opaque cursor for fetching the next page (keyset pagination).
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub next_cursor: Option<String>,
448    /// Whether more results are available after this page.
449    pub has_more: bool,
450}
451
452// ============================================================================
453// Inventory
454// ============================================================================
455
456/// Request body for `POST /api/v1/inventory/:sku/adjust`.
457#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
458pub struct InventoryAdjustRequest {
459    /// Signed quantity change (+/−).
460    #[schema(value_type = String)]
461    pub quantity: Decimal,
462    /// Reason for the adjustment.
463    pub reason: String,
464    /// Deprecated: location-scoped adjustments are not supported by this endpoint.
465    ///
466    /// Supplying this field will return a validation error.
467    pub location_id: Option<i32>,
468}
469
470/// Response body for inventory stock levels.
471#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
472pub struct InventoryResponse {
473    pub sku: String,
474    pub name: String,
475    #[schema(value_type = String)]
476    pub total_on_hand: Decimal,
477    #[schema(value_type = String)]
478    pub total_allocated: Decimal,
479    #[schema(value_type = String)]
480    pub total_available: Decimal,
481}
482
483// ============================================================================
484// Inventory (list)
485// ============================================================================
486
487/// Query parameters for `GET /api/v1/inventory` with filtering.
488#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
489#[into_params(parameter_in = Query)]
490pub struct InventoryFilterParams {
491    /// Maximum number of results to return (default: 50).
492    pub limit: Option<u32>,
493    /// Number of results to skip (default: 0).
494    pub offset: Option<u32>,
495    /// Filter by SKU (exact match).
496    pub sku: Option<String>,
497    /// Filter by items below reorder point.
498    pub below_reorder_point: Option<bool>,
499    /// Filter by active status.
500    pub is_active: Option<bool>,
501}
502
503impl InventoryFilterParams {
504    /// Resolved limit with bounds checking.
505    #[must_use]
506    pub fn resolved_limit(&self) -> u32 {
507        resolve_limit(self.limit)
508    }
509
510    /// Resolved offset.
511    #[must_use]
512    pub fn resolved_offset(&self) -> u32 {
513        self.offset.unwrap_or(0)
514    }
515}
516
517/// Response body for a single inventory item (list view).
518#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
519pub struct InventoryItemResponse {
520    pub id: i64,
521    pub sku: String,
522    pub name: String,
523    pub description: Option<String>,
524    pub unit_of_measure: String,
525    pub is_active: bool,
526    pub created_at: DateTime<Utc>,
527    pub updated_at: DateTime<Utc>,
528}
529
530/// Response body for `GET /api/v1/inventory` (list).
531#[derive(Debug, Clone, Serialize, ToSchema)]
532pub struct InventoryListResponse {
533    pub items: Vec<InventoryItemResponse>,
534    pub total: usize,
535    pub limit: u32,
536    pub offset: u32,
537    pub has_more: bool,
538}
539
540// ============================================================================
541// Shipments
542// ============================================================================
543
544/// Query parameters for `GET /api/v1/shipments` with filtering.
545#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
546#[into_params(parameter_in = Query)]
547pub struct ShipmentFilterParams {
548    /// Maximum number of results to return (default: 50).
549    pub limit: Option<u32>,
550    /// Number of results to skip (default: 0).
551    pub offset: Option<u32>,
552    /// Filter by order ID (UUID).
553    pub order_id: Option<String>,
554    /// Filter by shipment status (pending, shipped, `in_transit`, delivered, returned, cancelled).
555    pub status: Option<String>,
556    /// Filter by carrier (usps, ups, fedex, dhl, etc.).
557    pub carrier: Option<String>,
558    /// Filter by tracking number.
559    pub tracking_number: Option<String>,
560}
561
562impl ShipmentFilterParams {
563    /// Resolved limit with bounds checking.
564    #[must_use]
565    pub fn resolved_limit(&self) -> u32 {
566        resolve_limit(self.limit)
567    }
568
569    /// Resolved offset.
570    #[must_use]
571    pub fn resolved_offset(&self) -> u32 {
572        self.offset.unwrap_or(0)
573    }
574}
575
576/// Request body for `POST /api/v1/shipments`.
577#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
578pub struct CreateShipmentRequest {
579    #[schema(value_type = String, format = "uuid")]
580    pub order_id: stateset_primitives::OrderId,
581    pub carrier: Option<String>,
582    pub tracking_number: Option<String>,
583    pub shipping_method: Option<String>,
584    pub recipient_name: Option<String>,
585    pub notes: Option<String>,
586}
587
588/// Response body for a single shipment.
589#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
590pub struct ShipmentResponse {
591    #[schema(value_type = String, format = "uuid")]
592    pub id: stateset_primitives::ShipmentId,
593    pub shipment_number: String,
594    #[schema(value_type = String, format = "uuid")]
595    pub order_id: OrderId,
596    pub status: String,
597    pub carrier: String,
598    pub shipping_method: String,
599    pub tracking_number: Option<String>,
600    pub tracking_url: Option<String>,
601    pub recipient_name: String,
602    #[schema(value_type = Option<String>)]
603    pub shipping_cost: Option<Decimal>,
604    pub shipped_at: Option<DateTime<Utc>>,
605    pub estimated_delivery: Option<DateTime<Utc>>,
606    pub delivered_at: Option<DateTime<Utc>>,
607    pub created_at: DateTime<Utc>,
608    pub updated_at: DateTime<Utc>,
609}
610
611/// Response body for `GET /api/v1/shipments` (list).
612#[derive(Debug, Clone, Serialize, ToSchema)]
613pub struct ShipmentListResponse {
614    pub shipments: Vec<ShipmentResponse>,
615    pub total: usize,
616    pub limit: u32,
617    pub offset: u32,
618    pub has_more: bool,
619}
620
621// ============================================================================
622// Payments
623// ============================================================================
624
625/// Query parameters for `GET /api/v1/payments` with filtering.
626#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
627#[into_params(parameter_in = Query)]
628pub struct PaymentFilterParams {
629    /// Maximum number of results to return (default: 50).
630    pub limit: Option<u32>,
631    /// Number of results to skip (default: 0).
632    pub offset: Option<u32>,
633    /// Filter by order ID (UUID).
634    pub order_id: Option<String>,
635    /// Filter by customer ID (UUID).
636    pub customer_id: Option<String>,
637    /// Filter by payment status (pending, authorized, captured, failed, refunded, `partially_refunded`, voided).
638    pub status: Option<String>,
639    /// Filter by payment method (`credit_card`, `debit_card`, `bank_transfer`, etc.).
640    pub payment_method: Option<String>,
641    /// Filter by processor name.
642    pub processor: Option<String>,
643    /// Minimum payment amount.
644    pub min_amount: Option<String>,
645    /// Maximum payment amount.
646    pub max_amount: Option<String>,
647    /// Payments created on or after this date (RFC 3339).
648    pub from_date: Option<String>,
649    /// Payments created on or before this date (RFC 3339).
650    pub to_date: Option<String>,
651}
652
653impl PaymentFilterParams {
654    /// Resolved limit with bounds checking.
655    #[must_use]
656    pub fn resolved_limit(&self) -> u32 {
657        resolve_limit(self.limit)
658    }
659
660    /// Resolved offset.
661    #[must_use]
662    pub fn resolved_offset(&self) -> u32 {
663        self.offset.unwrap_or(0)
664    }
665}
666
667/// Request body for `POST /api/v1/payments`.
668#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
669pub struct CreatePaymentRequest {
670    #[schema(value_type = String, format = "uuid")]
671    pub order_id: stateset_primitives::OrderId,
672    #[schema(value_type = Option<String>, format = "uuid")]
673    pub customer_id: Option<stateset_primitives::CustomerId>,
674    pub payment_method: Option<String>,
675    #[schema(value_type = String)]
676    pub amount: Decimal,
677    pub currency: Option<String>,
678    pub external_id: Option<String>,
679    pub description: Option<String>,
680}
681
682/// Request body for `POST /api/v1/payments/:id/refund`.
683#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
684pub struct CreateRefundRequest {
685    #[schema(value_type = String)]
686    pub amount: Decimal,
687    pub reason: Option<String>,
688    pub notes: Option<String>,
689}
690
691/// Response body for a single payment.
692#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
693pub struct PaymentResponse {
694    #[schema(value_type = String, format = "uuid")]
695    pub id: stateset_primitives::PaymentId,
696    pub payment_number: String,
697    #[schema(value_type = Option<String>, format = "uuid")]
698    pub order_id: Option<OrderId>,
699    pub customer_id: Option<String>,
700    pub status: String,
701    pub payment_method: String,
702    #[schema(value_type = String)]
703    pub amount: Decimal,
704    pub currency: String,
705    #[schema(value_type = String)]
706    pub amount_refunded: Decimal,
707    pub external_id: Option<String>,
708    pub processor: Option<String>,
709    pub created_at: DateTime<Utc>,
710    pub updated_at: DateTime<Utc>,
711}
712
713/// Response body for `GET /api/v1/payments` (list).
714#[derive(Debug, Clone, Serialize, ToSchema)]
715pub struct PaymentListResponse {
716    pub payments: Vec<PaymentResponse>,
717    pub total: usize,
718    pub limit: u32,
719    pub offset: u32,
720    pub has_more: bool,
721}
722
723// ============================================================================
724// Invoices
725// ============================================================================
726
727/// Query parameters for `GET /api/v1/invoices` with filtering.
728#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
729#[into_params(parameter_in = Query)]
730pub struct InvoiceFilterParams {
731    /// Maximum number of results to return (default: 50).
732    pub limit: Option<u32>,
733    /// Number of results to skip (default: 0).
734    pub offset: Option<u32>,
735    /// Filter by customer ID (UUID).
736    pub customer_id: Option<String>,
737    /// Filter by order ID (UUID).
738    pub order_id: Option<String>,
739    /// Filter by invoice status (draft, sent, viewed, paid, overdue, voided).
740    pub status: Option<String>,
741    /// Filter by invoice type (standard, `credit_note`, `pro_forma`, recurring).
742    pub invoice_type: Option<String>,
743    /// Filter overdue invoices only.
744    pub overdue_only: Option<bool>,
745    /// Invoices created on or after this date (RFC 3339).
746    pub from_date: Option<String>,
747    /// Invoices created on or before this date (RFC 3339).
748    pub to_date: Option<String>,
749}
750
751impl InvoiceFilterParams {
752    /// Resolved limit with bounds checking.
753    #[must_use]
754    pub fn resolved_limit(&self) -> u32 {
755        resolve_limit(self.limit)
756    }
757
758    /// Resolved offset.
759    #[must_use]
760    pub fn resolved_offset(&self) -> u32 {
761        self.offset.unwrap_or(0)
762    }
763}
764
765/// Request body for `POST /api/v1/invoices`.
766#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
767pub struct CreateInvoiceRequest {
768    #[schema(value_type = String, format = "uuid")]
769    pub customer_id: stateset_primitives::CustomerId,
770    #[schema(value_type = Option<String>, format = "uuid")]
771    pub order_id: Option<stateset_primitives::OrderId>,
772    pub invoice_type: Option<String>,
773    pub due_date: Option<String>,
774    pub payment_terms: Option<String>,
775    pub currency: Option<String>,
776    pub notes: Option<String>,
777}
778
779/// Request body for `POST /api/v1/invoices/:id/payments`.
780#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
781pub struct RecordInvoicePaymentRequest {
782    #[schema(value_type = String)]
783    pub amount: Decimal,
784    pub payment_method: Option<String>,
785    pub reference: Option<String>,
786    pub notes: Option<String>,
787}
788
789/// Response body for a single invoice.
790#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
791pub struct InvoiceResponse {
792    #[schema(value_type = String, format = "uuid")]
793    pub id: stateset_primitives::InvoiceId,
794    pub invoice_number: String,
795    #[schema(value_type = String, format = "uuid")]
796    pub customer_id: CustomerId,
797    #[schema(value_type = Option<String>, format = "uuid")]
798    pub order_id: Option<OrderId>,
799    pub status: String,
800    pub invoice_type: String,
801    pub invoice_date: DateTime<Utc>,
802    pub due_date: DateTime<Utc>,
803    pub currency: String,
804    #[schema(value_type = String)]
805    pub subtotal: Decimal,
806    #[schema(value_type = String)]
807    pub tax_amount: Decimal,
808    #[schema(value_type = String)]
809    pub total: Decimal,
810    #[schema(value_type = String)]
811    pub amount_paid: Decimal,
812    #[schema(value_type = String)]
813    pub balance_due: Decimal,
814    pub created_at: DateTime<Utc>,
815    pub updated_at: DateTime<Utc>,
816}
817
818/// Response body for `GET /api/v1/invoices` (list).
819#[derive(Debug, Clone, Serialize, ToSchema)]
820pub struct InvoiceListResponse {
821    pub invoices: Vec<InvoiceResponse>,
822    pub total: usize,
823    pub limit: u32,
824    pub offset: u32,
825    pub has_more: bool,
826}
827
828// ============================================================================
829// Returns
830// ============================================================================
831
832/// Request body for `POST /api/v1/returns`.
833#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
834pub struct CreateReturnRequest {
835    #[schema(value_type = String, format = "uuid")]
836    pub order_id: OrderId,
837    pub reason: String,
838    pub reason_details: Option<String>,
839    pub items: Vec<CreateReturnItemRequest>,
840    pub notes: Option<String>,
841}
842
843/// A single item in a create-return request.
844#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
845pub struct CreateReturnItemRequest {
846    pub order_item_id: Uuid,
847    pub quantity: i32,
848    pub condition: Option<String>,
849}
850
851/// Response body for a single return.
852#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
853pub struct ReturnResponse {
854    #[schema(value_type = String, format = "uuid")]
855    pub id: ReturnId,
856    #[schema(value_type = String, format = "uuid")]
857    pub order_id: OrderId,
858    #[schema(value_type = String, format = "uuid")]
859    pub customer_id: CustomerId,
860    pub status: String,
861    pub reason: String,
862    #[schema(value_type = Option<String>)]
863    pub refund_amount: Option<Decimal>,
864    pub created_at: DateTime<Utc>,
865    pub updated_at: DateTime<Utc>,
866}
867
868/// Response body for `GET /api/v1/returns` (list).
869#[derive(Debug, Clone, Serialize, ToSchema)]
870pub struct ReturnListResponse {
871    pub returns: Vec<ReturnResponse>,
872    pub total: usize,
873    pub limit: u32,
874    pub offset: u32,
875    /// Opaque cursor for fetching the next page (keyset pagination).
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub next_cursor: Option<String>,
878    /// Whether more results are available after this page.
879    pub has_more: bool,
880}
881
882// ============================================================================
883// Health
884// ============================================================================
885
886/// Tenant cache status included in health and readiness responses.
887#[derive(Debug, Clone, Serialize, ToSchema)]
888pub struct TenantCacheResponse {
889    pub enabled: bool,
890    pub max_cached_dbs: usize,
891    pub cached_dbs: usize,
892    pub in_use_cached_dbs: usize,
893    pub hits: u64,
894    pub misses: u64,
895    pub evictions: u64,
896    pub rejections: u64,
897}
898
899/// Response body for `GET /health`.
900#[derive(Debug, Clone, Serialize, ToSchema)]
901pub struct HealthResponse {
902    pub status: &'static str,
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub tenant_cache: Option<TenantCacheResponse>,
905}
906
907/// Response body for `GET /health/ready`.
908#[derive(Debug, Clone, Serialize, ToSchema)]
909pub struct ReadyResponse {
910    pub status: &'static str,
911    pub database: &'static str,
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub tenant_cache: Option<TenantCacheResponse>,
914}
915
916/// Response body for `GET /version` — build & release metadata.
917///
918/// All fields except `version` are best-effort: they're set at compile
919/// time from environment variables the release pipeline injects. When a
920/// build runs without those variables set (e.g. local `cargo build`),
921/// the corresponding fields are `None` and operators can interpret that
922/// as "this binary did not come from a verified release pipeline".
923///
924/// The companion admin route (Phase 4.4) consumes this endpoint to
925/// display sigstore-verified release information so operators can audit
926/// what's actually running in production.
927#[derive(Debug, Clone, Serialize, ToSchema)]
928pub struct VersionResponse {
929    /// Package version from `Cargo.toml` (always present).
930    pub version: &'static str,
931
932    /// Git commit SHA (full or short) of the build, if injected via
933    /// `GITHUB_SHA` at compile time. `None` for unverified local builds.
934    #[serde(skip_serializing_if = "Option::is_none")]
935    pub git_commit: Option<&'static str>,
936
937    /// Git branch or tag name, if injected via `GITHUB_REF_NAME`.
938    #[serde(skip_serializing_if = "Option::is_none")]
939    pub git_ref: Option<&'static str>,
940
941    /// Release tag (e.g. `v1.0.3`) if this binary came from a tagged
942    /// release. Distinct from `git_ref` because release builds set this
943    /// explicitly via `STATESET_RELEASE_TAG`.
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub release_tag: Option<&'static str>,
946
947    /// RFC 3339 build timestamp, if injected via
948    /// `STATESET_BUILD_TIMESTAMP` at compile time.
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub built_at: Option<&'static str>,
951
952    /// Whether this binary's release artifacts were signed via sigstore.
953    /// `true` when the release pipeline injected `STATESET_SIGNED=true`;
954    /// `false` (the default) for local builds, dev builds, and any
955    /// release where signing was skipped or failed.
956    pub signed: bool,
957}
958
959// ============================================================================
960// Events
961// ============================================================================
962
963/// Query parameters for the SSE event stream endpoint.
964#[derive(Debug, Clone, Deserialize, Default, IntoParams)]
965#[into_params(parameter_in = Query)]
966pub struct EventStreamParams {
967    /// Optional event type filter (e.g. `order.*`).
968    pub filter: Option<String>,
969    /// Resume the stream after this event id. Equivalent to the standard SSE
970    /// `Last-Event-ID` request header; the header takes precedence when both
971    /// are supplied. Events with a greater id are replayed from the bounded
972    /// server-side buffer before the live stream resumes.
973    pub last_event_id: Option<u64>,
974}
975
976// ============================================================================
977// Conversion helpers
978// ============================================================================
979
980impl From<stateset_core::Order> for OrderResponse {
981    fn from(o: stateset_core::Order) -> Self {
982        Self {
983            id: o.id,
984            order_number: o.order_number,
985            customer_id: o.customer_id,
986            status: o.status.to_string(),
987            total_amount: o.total_amount,
988            currency: o.currency.to_string(),
989            payment_status: o.payment_status.to_string(),
990            fulfillment_status: o.fulfillment_status.to_string(),
991            items: o.items.into_iter().map(OrderItemResponse::from).collect(),
992            created_at: o.created_at,
993            updated_at: o.updated_at,
994        }
995    }
996}
997
998impl From<stateset_core::OrderItem> for OrderItemResponse {
999    fn from(i: stateset_core::OrderItem) -> Self {
1000        Self {
1001            id: *i.id.as_uuid(),
1002            product_id: i.product_id,
1003            sku: i.sku,
1004            name: i.name,
1005            quantity: i.quantity,
1006            unit_price: i.unit_price,
1007            total: i.total,
1008        }
1009    }
1010}
1011
1012impl From<stateset_core::Customer> for CustomerResponse {
1013    fn from(c: stateset_core::Customer) -> Self {
1014        Self {
1015            id: c.id,
1016            email: c.email,
1017            first_name: c.first_name,
1018            last_name: c.last_name,
1019            phone: c.phone,
1020            status: c.status.to_string(),
1021            accepts_marketing: c.accepts_marketing,
1022            created_at: c.created_at,
1023            updated_at: c.updated_at,
1024        }
1025    }
1026}
1027
1028impl From<stateset_core::Product> for ProductResponse {
1029    fn from(p: stateset_core::Product) -> Self {
1030        Self {
1031            id: p.id,
1032            name: p.name,
1033            slug: p.slug,
1034            description: p.description,
1035            status: p.status.to_string(),
1036            product_type: p.product_type.to_string(),
1037            created_at: p.created_at,
1038            updated_at: p.updated_at,
1039        }
1040    }
1041}
1042
1043impl From<stateset_core::StockLevel> for InventoryResponse {
1044    fn from(s: stateset_core::StockLevel) -> Self {
1045        Self {
1046            sku: s.sku,
1047            name: s.name,
1048            total_on_hand: s.total_on_hand,
1049            total_allocated: s.total_allocated,
1050            total_available: s.total_available,
1051        }
1052    }
1053}
1054
1055impl From<stateset_core::InventoryItem> for InventoryItemResponse {
1056    fn from(i: stateset_core::InventoryItem) -> Self {
1057        Self {
1058            id: i.id,
1059            sku: i.sku,
1060            name: i.name,
1061            description: i.description,
1062            unit_of_measure: i.unit_of_measure,
1063            is_active: i.is_active,
1064            created_at: i.created_at,
1065            updated_at: i.updated_at,
1066        }
1067    }
1068}
1069
1070impl From<stateset_core::Shipment> for ShipmentResponse {
1071    fn from(s: stateset_core::Shipment) -> Self {
1072        Self {
1073            id: s.id,
1074            shipment_number: s.shipment_number,
1075            order_id: s.order_id,
1076            status: s.status.to_string(),
1077            carrier: s.carrier.to_string(),
1078            shipping_method: s.shipping_method.to_string(),
1079            tracking_number: s.tracking_number,
1080            tracking_url: s.tracking_url,
1081            recipient_name: s.recipient_name,
1082            shipping_cost: s.shipping_cost,
1083            shipped_at: s.shipped_at,
1084            estimated_delivery: s.estimated_delivery,
1085            delivered_at: s.delivered_at,
1086            created_at: s.created_at,
1087            updated_at: s.updated_at,
1088        }
1089    }
1090}
1091
1092impl From<stateset_core::Payment> for PaymentResponse {
1093    fn from(p: stateset_core::Payment) -> Self {
1094        Self {
1095            id: p.id,
1096            payment_number: p.payment_number,
1097            order_id: p.order_id,
1098            customer_id: p.customer_id.map(|c| c.to_string()),
1099            status: p.status.to_string(),
1100            payment_method: p.payment_method.to_string(),
1101            amount: p.amount,
1102            currency: p.currency.to_string(),
1103            amount_refunded: p.amount_refunded,
1104            external_id: p.external_id,
1105            processor: p.processor,
1106            created_at: p.created_at,
1107            updated_at: p.updated_at,
1108        }
1109    }
1110}
1111
1112impl From<stateset_core::Invoice> for InvoiceResponse {
1113    fn from(i: stateset_core::Invoice) -> Self {
1114        Self {
1115            id: i.id,
1116            invoice_number: i.invoice_number,
1117            customer_id: i.customer_id,
1118            order_id: i.order_id,
1119            status: i.status.to_string(),
1120            invoice_type: i.invoice_type.to_string(),
1121            invoice_date: i.invoice_date,
1122            due_date: i.due_date,
1123            currency: i.currency.to_string(),
1124            subtotal: i.subtotal,
1125            tax_amount: i.tax_amount,
1126            total: i.total,
1127            amount_paid: i.amount_paid,
1128            balance_due: i.balance_due,
1129            created_at: i.created_at,
1130            updated_at: i.updated_at,
1131        }
1132    }
1133}
1134
1135impl From<stateset_core::Return> for ReturnResponse {
1136    fn from(r: stateset_core::Return) -> Self {
1137        Self {
1138            id: r.id,
1139            order_id: r.order_id,
1140            customer_id: r.customer_id,
1141            status: r.status.to_string(),
1142            reason: r.reason.to_string(),
1143            refund_amount: r.refund_amount,
1144            created_at: r.created_at,
1145            updated_at: r.updated_at,
1146        }
1147    }
1148}
1149
1150impl From<AddressDto> for stateset_core::Address {
1151    fn from(a: AddressDto) -> Self {
1152        Self {
1153            line1: a.line1,
1154            line2: a.line2,
1155            city: a.city,
1156            state: a.state,
1157            postal_code: a.postal_code,
1158            country: a.country,
1159        }
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166    use rust_decimal_macros::dec;
1167
1168    // ============================================================================
1169    // PaginationParams tests
1170    // ============================================================================
1171
1172    #[test]
1173    fn pagination_default_limit() {
1174        let p = PaginationParams::default();
1175        assert_eq!(p.resolved_limit(), PaginationParams::DEFAULT_LIMIT);
1176        assert_eq!(p.resolved_offset(), 0);
1177    }
1178
1179    #[test]
1180    fn pagination_custom_limit() {
1181        let p = PaginationParams { limit: Some(10), offset: Some(20) };
1182        assert_eq!(p.resolved_limit(), 10);
1183        assert_eq!(p.resolved_offset(), 20);
1184    }
1185
1186    #[test]
1187    fn pagination_clamps_to_max() {
1188        let p = PaginationParams { limit: Some(999), offset: None };
1189        assert_eq!(p.resolved_limit(), PaginationParams::MAX_LIMIT);
1190    }
1191
1192    #[test]
1193    fn pagination_clamps_zero_to_one() {
1194        let p = PaginationParams { limit: Some(0), offset: None };
1195        assert_eq!(p.resolved_limit(), 1);
1196    }
1197
1198    // ============================================================================
1199    // Money DTO precision tests
1200    //
1201    // Monetary request fields must deserialize as exact decimals (string form),
1202    // matching PaymentResponse/InvoiceResponse serialization, while continuing
1203    // to accept plain JSON numbers for wire compatibility.
1204    // ============================================================================
1205
1206    #[test]
1207    fn payment_amount_accepts_exact_decimal_string() {
1208        let req: CreatePaymentRequest = serde_json::from_str(
1209            r#"{"order_id":"01234567-89ab-cdef-0123-456789abcdef","amount":"123.456789012345678901"}"#,
1210        )
1211        .expect("string-encoded amount must deserialize exactly");
1212        assert_eq!(req.amount.to_string(), "123.456789012345678901");
1213    }
1214
1215    #[test]
1216    fn refund_amount_accepts_exact_decimal_string() {
1217        let req: CreateRefundRequest = serde_json::from_str(r#"{"amount":"0.30"}"#)
1218            .expect("string-encoded amount must deserialize exactly");
1219        assert_eq!(req.amount.to_string(), "0.30");
1220    }
1221
1222    #[test]
1223    fn refund_amount_still_accepts_json_number() {
1224        let req: CreateRefundRequest = serde_json::from_str(r#"{"amount":49.99}"#)
1225            .expect("plain JSON number amount must keep deserializing");
1226        assert_eq!(req.amount.to_string(), "49.99");
1227    }
1228
1229    #[test]
1230    fn invoice_payment_amount_accepts_exact_decimal_string() {
1231        let req: RecordInvoicePaymentRequest =
1232            serde_json::from_str(r#"{"amount":"1000000000000.000001"}"#)
1233                .expect("string-encoded amount must deserialize exactly");
1234        assert_eq!(req.amount.to_string(), "1000000000000.000001");
1235    }
1236
1237    // ============================================================================
1238    // DTO serialization tests
1239    // ============================================================================
1240
1241    #[test]
1242    fn create_order_request_roundtrip() {
1243        let req = CreateOrderRequest {
1244            customer_id: CustomerId::new(),
1245            items: vec![CreateOrderItemRequest {
1246                product_id: ProductId::new(),
1247                variant_id: None,
1248                sku: "SKU-1".into(),
1249                name: "Widget".into(),
1250                quantity: 2,
1251                unit_price: dec!(29.99),
1252                discount: None,
1253                tax_amount: None,
1254            }],
1255            currency: Some("USD".into()),
1256            shipping_address: Some(AddressDto {
1257                line1: "123 Main".into(),
1258                line2: None,
1259                city: "NYC".into(),
1260                state: Some("NY".into()),
1261                postal_code: "10001".into(),
1262                country: "US".into(),
1263            }),
1264            billing_address: None,
1265            notes: None,
1266            payment_method: None,
1267            shipping_method: None,
1268        };
1269        let json = serde_json::to_string(&req).unwrap();
1270        let deser: CreateOrderRequest = serde_json::from_str(&json).unwrap();
1271        assert_eq!(deser.items.len(), 1);
1272        assert_eq!(deser.items[0].sku, "SKU-1");
1273    }
1274
1275    #[test]
1276    fn create_customer_request_roundtrip() {
1277        let req = CreateCustomerRequest {
1278            email: "test@example.com".into(),
1279            first_name: "John".into(),
1280            last_name: "Doe".into(),
1281            phone: None,
1282            accepts_marketing: Some(true),
1283            tags: None,
1284            metadata: None,
1285        };
1286        let json = serde_json::to_string(&req).unwrap();
1287        let deser: CreateCustomerRequest = serde_json::from_str(&json).unwrap();
1288        assert_eq!(deser.email, "test@example.com");
1289    }
1290
1291    #[test]
1292    fn create_product_request_roundtrip() {
1293        let req = CreateProductRequest {
1294            name: "Widget".into(),
1295            slug: Some("widget".into()),
1296            description: Some("A fine widget".into()),
1297            product_type: None,
1298        };
1299        let json = serde_json::to_string(&req).unwrap();
1300        let deser: CreateProductRequest = serde_json::from_str(&json).unwrap();
1301        assert_eq!(deser.name, "Widget");
1302    }
1303
1304    #[test]
1305    fn inventory_adjust_request_roundtrip() {
1306        let req = InventoryAdjustRequest {
1307            quantity: dec!(-5),
1308            reason: "Damaged".into(),
1309            location_id: Some(1),
1310        };
1311        let json = serde_json::to_string(&req).unwrap();
1312        let deser: InventoryAdjustRequest = serde_json::from_str(&json).unwrap();
1313        assert_eq!(deser.quantity, dec!(-5));
1314    }
1315
1316    #[test]
1317    fn create_return_request_roundtrip() {
1318        let req = CreateReturnRequest {
1319            order_id: OrderId::new(),
1320            reason: "defective".into(),
1321            reason_details: None,
1322            items: vec![CreateReturnItemRequest {
1323                order_item_id: Uuid::new_v4(),
1324                quantity: 1,
1325                condition: Some("damaged".into()),
1326            }],
1327            notes: None,
1328        };
1329        let json = serde_json::to_string(&req).unwrap();
1330        let deser: CreateReturnRequest = serde_json::from_str(&json).unwrap();
1331        assert_eq!(deser.items.len(), 1);
1332    }
1333
1334    #[test]
1335    fn health_response_serialization() {
1336        let resp = HealthResponse {
1337            status: "ok",
1338            tenant_cache: Some(TenantCacheResponse {
1339                enabled: true,
1340                max_cached_dbs: 256,
1341                cached_dbs: 3,
1342                in_use_cached_dbs: 1,
1343                hits: 20,
1344                misses: 4,
1345                evictions: 2,
1346                rejections: 1,
1347            }),
1348        };
1349        let json = serde_json::to_value(&resp).unwrap();
1350        assert_eq!(json["status"], "ok");
1351        assert_eq!(json["tenant_cache"]["cached_dbs"], 3);
1352    }
1353
1354    #[test]
1355    fn ready_response_serialization() {
1356        let resp = ReadyResponse {
1357            status: "ok",
1358            database: "connected",
1359            tenant_cache: Some(TenantCacheResponse {
1360                enabled: false,
1361                max_cached_dbs: 256,
1362                cached_dbs: 0,
1363                in_use_cached_dbs: 0,
1364                hits: 0,
1365                misses: 0,
1366                evictions: 0,
1367                rejections: 0,
1368            }),
1369        };
1370        let json = serde_json::to_value(&resp).unwrap();
1371        assert_eq!(json["database"], "connected");
1372        assert_eq!(json["tenant_cache"]["enabled"], false);
1373    }
1374
1375    #[test]
1376    fn order_response_serialization() {
1377        let resp = OrderResponse {
1378            id: OrderId::new(),
1379            order_number: "ORD-001".into(),
1380            customer_id: CustomerId::new(),
1381            status: "pending".into(),
1382            total_amount: dec!(59.98),
1383            currency: "USD".into(),
1384            payment_status: "pending".into(),
1385            fulfillment_status: "unfulfilled".into(),
1386            items: vec![],
1387            created_at: Utc::now(),
1388            updated_at: Utc::now(),
1389        };
1390        let json = serde_json::to_value(&resp).unwrap();
1391        assert_eq!(json["status"], "pending");
1392    }
1393
1394    #[test]
1395    fn customer_response_serialization() {
1396        let resp = CustomerResponse {
1397            id: CustomerId::new(),
1398            email: "a@b.com".into(),
1399            first_name: "A".into(),
1400            last_name: "B".into(),
1401            phone: None,
1402            status: "active".into(),
1403            accepts_marketing: false,
1404            created_at: Utc::now(),
1405            updated_at: Utc::now(),
1406        };
1407        let json = serde_json::to_value(&resp).unwrap();
1408        assert_eq!(json["email"], "a@b.com");
1409    }
1410
1411    #[test]
1412    fn product_response_serialization() {
1413        let resp = ProductResponse {
1414            id: ProductId::new(),
1415            name: "W".into(),
1416            slug: "w".into(),
1417            description: "d".into(),
1418            status: "draft".into(),
1419            product_type: "simple".into(),
1420            created_at: Utc::now(),
1421            updated_at: Utc::now(),
1422        };
1423        let json = serde_json::to_value(&resp).unwrap();
1424        assert_eq!(json["name"], "W");
1425    }
1426
1427    #[test]
1428    fn inventory_response_serialization() {
1429        let resp = InventoryResponse {
1430            sku: "SKU-1".into(),
1431            name: "Widget".into(),
1432            total_on_hand: dec!(100),
1433            total_allocated: dec!(10),
1434            total_available: dec!(90),
1435        };
1436        let json = serde_json::to_value(&resp).unwrap();
1437        assert_eq!(json["sku"], "SKU-1");
1438    }
1439
1440    #[test]
1441    fn return_response_serialization() {
1442        let resp = ReturnResponse {
1443            id: ReturnId::new(),
1444            order_id: OrderId::new(),
1445            customer_id: CustomerId::new(),
1446            status: "requested".into(),
1447            reason: "defective".into(),
1448            refund_amount: Some(dec!(29.99)),
1449            created_at: Utc::now(),
1450            updated_at: Utc::now(),
1451        };
1452        let json = serde_json::to_value(&resp).unwrap();
1453        assert_eq!(json["status"], "requested");
1454    }
1455
1456    #[test]
1457    fn address_dto_converts_to_core() {
1458        let dto = AddressDto {
1459            line1: "123 Main".into(),
1460            line2: None,
1461            city: "NYC".into(),
1462            state: Some("NY".into()),
1463            postal_code: "10001".into(),
1464            country: "US".into(),
1465        };
1466        let core: stateset_core::Address = dto.into();
1467        assert_eq!(core.city, "NYC");
1468    }
1469
1470    #[test]
1471    fn event_stream_params_default() {
1472        let p = EventStreamParams::default();
1473        assert!(p.filter.is_none());
1474    }
1475
1476    #[test]
1477    fn order_list_response_serialization() {
1478        let resp = OrderListResponse {
1479            orders: vec![],
1480            total: 0,
1481            limit: 50,
1482            offset: 0,
1483            next_cursor: None,
1484            has_more: false,
1485        };
1486        let json = serde_json::to_value(&resp).unwrap();
1487        assert_eq!(json["total"], 0);
1488        assert_eq!(json["has_more"], false);
1489        assert!(json.get("next_cursor").is_none()); // skipped when None
1490    }
1491
1492    #[test]
1493    fn customer_list_response_serialization() {
1494        let resp = CustomerListResponse {
1495            customers: vec![],
1496            total: 0,
1497            limit: 50,
1498            offset: 0,
1499            next_cursor: None,
1500            has_more: false,
1501        };
1502        let json = serde_json::to_value(&resp).unwrap();
1503        assert_eq!(json["total"], 0);
1504        assert_eq!(json["has_more"], false);
1505    }
1506
1507    #[test]
1508    fn product_list_response_serialization() {
1509        let resp = ProductListResponse {
1510            products: vec![],
1511            total: 0,
1512            limit: 50,
1513            offset: 0,
1514            next_cursor: None,
1515            has_more: false,
1516        };
1517        let json = serde_json::to_value(&resp).unwrap();
1518        assert_eq!(json["total"], 0);
1519        assert_eq!(json["has_more"], false);
1520    }
1521
1522    #[test]
1523    fn return_list_response_serialization() {
1524        let resp = ReturnListResponse {
1525            returns: vec![],
1526            total: 0,
1527            limit: 50,
1528            offset: 0,
1529            next_cursor: None,
1530            has_more: false,
1531        };
1532        let json = serde_json::to_value(&resp).unwrap();
1533        assert_eq!(json["total"], 0);
1534        assert_eq!(json["has_more"], false);
1535    }
1536
1537    // ============================================================================
1538    // Cursor helpers tests
1539    // ============================================================================
1540
1541    #[test]
1542    fn cursor_encode_decode_roundtrip() {
1543        let cursor = encode_cursor("2024-01-15T10:30:00Z", "550e8400-e29b-41d4-a716-446655440000");
1544        let (sort_key, id) = decode_cursor(&cursor).unwrap();
1545        assert_eq!(sort_key, "2024-01-15T10:30:00Z");
1546        assert_eq!(id, "550e8400-e29b-41d4-a716-446655440000");
1547    }
1548
1549    #[test]
1550    fn cursor_decode_invalid_base64() {
1551        assert!(decode_cursor("not-valid-base64!!!").is_none());
1552    }
1553
1554    #[test]
1555    fn cursor_decode_missing_separator() {
1556        let encoded = URL_SAFE_NO_PAD.encode(b"no-separator-here");
1557        assert!(decode_cursor(&encoded).is_none());
1558    }
1559
1560    #[test]
1561    fn overfetch_limit_adds_one_row() {
1562        assert_eq!(overfetch_limit(10), 11);
1563        assert_eq!(overfetch_limit(PaginationParams::MAX_LIMIT), PaginationParams::MAX_LIMIT + 1);
1564    }
1565
1566    #[test]
1567    fn finalize_page_marks_exact_boundary_as_not_has_more() {
1568        let mut items = vec![1, 2];
1569        let has_more = finalize_page(&mut items, 2);
1570        assert!(!has_more);
1571        assert_eq!(items, vec![1, 2]);
1572    }
1573
1574    #[test]
1575    fn finalize_page_trims_overfetch_and_sets_has_more() {
1576        let mut items = vec![1, 2, 3];
1577        let has_more = finalize_page(&mut items, 2);
1578        assert!(has_more);
1579        assert_eq!(items, vec![1, 2]);
1580    }
1581
1582    #[test]
1583    fn cursor_next_cursor_serialized_when_present() {
1584        let resp = OrderListResponse {
1585            orders: vec![],
1586            total: 100,
1587            limit: 10,
1588            offset: 0,
1589            next_cursor: Some("abc123".into()),
1590            has_more: true,
1591        };
1592        let json = serde_json::to_value(&resp).unwrap();
1593        assert_eq!(json["next_cursor"], "abc123");
1594        assert_eq!(json["has_more"], true);
1595    }
1596
1597    // ============================================================================
1598    // Filter params deserialization tests
1599    // ============================================================================
1600
1601    #[test]
1602    fn order_filter_params_default() {
1603        let p = OrderFilterParams::default();
1604        assert_eq!(p.resolved_limit(), PaginationParams::DEFAULT_LIMIT);
1605        assert_eq!(p.resolved_offset(), 0);
1606        assert!(p.customer_id.is_none());
1607        assert!(p.status.is_none());
1608    }
1609
1610    #[test]
1611    fn order_filter_params_deserialization() {
1612        let p: OrderFilterParams = serde_json::from_value(serde_json::json!({
1613            "limit": 10, "offset": 5, "status": "pending", "customer_id": "abc"
1614        }))
1615        .unwrap();
1616        assert_eq!(p.resolved_limit(), 10);
1617        assert_eq!(p.resolved_offset(), 5);
1618        assert_eq!(p.status.as_deref(), Some("pending"));
1619        assert_eq!(p.customer_id.as_deref(), Some("abc"));
1620    }
1621
1622    #[test]
1623    fn customer_filter_params_deserialization() {
1624        let p: CustomerFilterParams = serde_json::from_value(serde_json::json!({
1625            "email": "test@example.com", "accepts_marketing": true
1626        }))
1627        .unwrap();
1628        assert_eq!(p.email.as_deref(), Some("test@example.com"));
1629        assert_eq!(p.accepts_marketing, Some(true));
1630    }
1631
1632    #[test]
1633    fn product_filter_params_deserialization() {
1634        let p: ProductFilterParams = serde_json::from_value(serde_json::json!({
1635            "search": "widget", "min_price": "10.00", "max_price": "100", "in_stock": true
1636        }))
1637        .unwrap();
1638        assert_eq!(p.search.as_deref(), Some("widget"));
1639        assert_eq!(p.min_price.as_deref(), Some("10.00"));
1640        assert_eq!(p.max_price.as_deref(), Some("100"));
1641        assert_eq!(p.in_stock, Some(true));
1642    }
1643
1644    #[test]
1645    fn return_filter_params_deserialization() {
1646        let p: ReturnFilterParams = serde_json::from_value(serde_json::json!({
1647            "status": "requested", "reason": "defective", "limit": 5
1648        }))
1649        .unwrap();
1650        assert_eq!(p.status.as_deref(), Some("requested"));
1651        assert_eq!(p.reason.as_deref(), Some("defective"));
1652        assert_eq!(p.resolved_limit(), 5);
1653    }
1654
1655    #[test]
1656    fn filter_params_limit_clamps_to_max() {
1657        let p = OrderFilterParams { limit: Some(999), ..Default::default() };
1658        assert_eq!(p.resolved_limit(), PaginationParams::MAX_LIMIT);
1659    }
1660
1661    #[test]
1662    fn filter_params_limit_clamps_zero_to_one() {
1663        let p = OrderFilterParams { limit: Some(0), ..Default::default() };
1664        assert_eq!(p.resolved_limit(), 1);
1665    }
1666}