Skip to main content

coil_commerce/
catalog.rs

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}