1use crate::checkout::CheckoutLine;
2use crate::error::CommerceModelError;
3use crate::identifiers::{CollectionHandle, CollectionId, ProductHandle, ProductId, Sku};
4use crate::model::{ProductKind, ProductStatus};
5use crate::validation::require_non_empty;
6use coil_data::{
7 FilterOperator, PageRequest, PublicationVisibility, QueryCacheScope, QueryContext, QueryFilter,
8 QuerySort, QuerySpec,
9};
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ProductVariant {
14 pub sku: Sku,
15 pub title: String,
16 pub list_price: crate::model::Money,
17}
18
19impl ProductVariant {
20 pub fn new(
21 sku: Sku,
22 title: impl Into<String>,
23 list_price: crate::model::Money,
24 ) -> Result<Self, CommerceModelError> {
25 Ok(Self {
26 sku,
27 title: require_non_empty("variant_title", title.into())?,
28 list_price,
29 })
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CatalogProduct {
35 pub id: ProductId,
36 pub handle: ProductHandle,
37 pub title: String,
38 pub kind: ProductKind,
39 pub status: ProductStatus,
40 variants: BTreeMap<Sku, ProductVariant>,
41}
42
43impl CatalogProduct {
44 pub fn new(
45 id: ProductId,
46 handle: ProductHandle,
47 title: impl Into<String>,
48 kind: ProductKind,
49 ) -> Result<Self, CommerceModelError> {
50 Ok(Self {
51 id,
52 handle,
53 title: require_non_empty("product_title", title.into())?,
54 kind,
55 status: ProductStatus::Draft,
56 variants: BTreeMap::new(),
57 })
58 }
59
60 pub fn activate(mut self) -> Self {
61 self.status = ProductStatus::Active;
62 self
63 }
64
65 pub fn archive(mut self) -> Self {
66 self.status = ProductStatus::Archived;
67 self
68 }
69
70 pub fn with_variant(mut self, variant: ProductVariant) -> Result<Self, CommerceModelError> {
71 if self.variants.contains_key(&variant.sku) {
72 return Err(CommerceModelError::DuplicateVariant {
73 sku: variant.sku.to_string(),
74 });
75 }
76
77 self.variants.insert(variant.sku.clone(), variant);
78 Ok(self)
79 }
80
81 pub fn variants(&self) -> impl Iterator<Item = &ProductVariant> {
82 self.variants.values()
83 }
84
85 pub fn variant(&self, sku: &Sku) -> Result<&ProductVariant, CommerceModelError> {
86 self.variants
87 .get(sku)
88 .ok_or_else(|| CommerceModelError::MissingVariant {
89 sku: sku.to_string(),
90 })
91 }
92
93 pub fn is_sellable(&self) -> bool {
94 self.status == ProductStatus::Active && !self.variants.is_empty()
95 }
96
97 pub fn checkout_line(
98 &self,
99 sku: &Sku,
100 quantity: u32,
101 ) -> Result<CheckoutLine, CommerceModelError> {
102 if self.status != ProductStatus::Active {
103 return Err(CommerceModelError::ProductNotSellable {
104 product_id: self.id.to_string(),
105 status: self.status,
106 });
107 }
108
109 let variant = self.variant(sku)?;
110 CheckoutLine::new(
111 self.id.clone(),
112 self.kind.clone(),
113 self.title.clone(),
114 variant.sku.clone(),
115 variant.title.clone(),
116 quantity,
117 variant.list_price.clone(),
118 )
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CatalogCollection {
124 pub id: CollectionId,
125 pub handle: CollectionHandle,
126 pub title: String,
127 product_ids: BTreeSet<ProductId>,
128}
129
130impl CatalogCollection {
131 pub fn new(
132 id: CollectionId,
133 handle: CollectionHandle,
134 title: impl Into<String>,
135 ) -> Result<Self, CommerceModelError> {
136 Ok(Self {
137 id,
138 handle,
139 title: require_non_empty("collection_title", title.into())?,
140 product_ids: BTreeSet::new(),
141 })
142 }
143
144 pub fn include_product(mut self, product_id: ProductId) -> Self {
145 self.product_ids.insert(product_id);
146 self
147 }
148
149 pub fn product_ids(&self) -> &BTreeSet<ProductId> {
150 &self.product_ids
151 }
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct Catalog {
156 products: BTreeMap<ProductId, CatalogProduct>,
157 collections: BTreeMap<CollectionId, CatalogCollection>,
158}
159
160impl Catalog {
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 pub fn insert_product(&mut self, product: CatalogProduct) -> Result<(), CommerceModelError> {
166 if self.products.contains_key(&product.id) {
167 return Err(CommerceModelError::DuplicateProduct {
168 product_id: product.id.to_string(),
169 });
170 }
171
172 self.products.insert(product.id.clone(), product);
173 Ok(())
174 }
175
176 pub fn insert_collection(
177 &mut self,
178 collection: CatalogCollection,
179 ) -> Result<(), CommerceModelError> {
180 if self.collections.contains_key(&collection.id) {
181 return Err(CommerceModelError::DuplicateCollection {
182 collection_id: collection.id.to_string(),
183 });
184 }
185
186 self.collections.insert(collection.id.clone(), collection);
187 Ok(())
188 }
189
190 pub fn product(&self, id: &ProductId) -> Result<&CatalogProduct, CommerceModelError> {
191 self.products
192 .get(id)
193 .ok_or_else(|| CommerceModelError::MissingProduct {
194 product_id: id.to_string(),
195 })
196 }
197
198 pub fn collection(&self, id: &CollectionId) -> Result<&CatalogCollection, CommerceModelError> {
199 self.collections
200 .get(id)
201 .ok_or_else(|| CommerceModelError::MissingCollection {
202 collection_id: id.to_string(),
203 })
204 }
205
206 pub fn collection_products(
207 &self,
208 collection_id: &CollectionId,
209 ) -> Result<Vec<&CatalogProduct>, CommerceModelError> {
210 let collection = self.collection(collection_id)?;
211 collection
212 .product_ids()
213 .iter()
214 .map(|product_id| self.product(product_id))
215 .collect()
216 }
217
218 pub fn storefront_listing_query(
219 &self,
220 locale: Option<&str>,
221 collection_handle: Option<&CollectionHandle>,
222 ) -> Result<CatalogListingQuery, CommerceModelError> {
223 let mut query = QuerySpec::new(
224 PageRequest::new(0, 24)?,
225 QueryContext {
226 locale: locale.map(str::to_owned),
227 principal_id: None,
228 publication_visibility: PublicationVisibility::PublishedOnly,
229 cache_scope: if locale.is_some() {
230 QueryCacheScope::LocaleScoped
231 } else {
232 QueryCacheScope::Public
233 },
234 },
235 )
236 .with_filter(QueryFilter::new(
237 "catalog_status",
238 FilterOperator::Eq,
239 vec![ProductStatus::Active.to_string()],
240 )?)
241 .with_sort(QuerySort::ascending("product_title")?);
242
243 if let Some(collection_handle) = collection_handle {
244 query = query.with_filter(QueryFilter::new(
245 "collection_handle",
246 FilterOperator::Eq,
247 vec![collection_handle.as_str().to_string()],
248 )?);
249 }
250
251 Ok(CatalogListingQuery { query })
252 }
253
254 pub fn admin_catalog_query(
255 &self,
256 principal_id: &str,
257 locale: Option<&str>,
258 ) -> Result<CatalogListingQuery, CommerceModelError> {
259 let query = QuerySpec::new(
260 PageRequest::new(0, 50)?,
261 QueryContext {
262 locale: locale.map(str::to_owned),
263 principal_id: Some(require_non_empty("principal_id", principal_id.to_string())?),
264 publication_visibility: PublicationVisibility::IncludeDrafts,
265 cache_scope: QueryCacheScope::UserScoped,
266 },
267 )
268 .with_sort(QuerySort::ascending("product_title")?);
269
270 Ok(CatalogListingQuery { query })
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct CatalogListingQuery {
276 pub query: QuerySpec,
277}