1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Basket {
17 pub id: String,
18 #[serde(default)]
19 pub items: Vec<BasketItem>,
20 #[serde(default)]
21 pub price_summary: PriceSummary,
22 pub store: Option<Store>,
23 #[serde(default)]
24 pub user_info: UserInfo,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct BasketItem {
30 pub id: String,
37 #[serde(default)]
38 pub ean: String,
39 #[serde(default)]
40 pub name: LocalizedName,
41 #[serde(default)]
42 pub amount_info: AmountInfo,
43 #[serde(default)]
44 pub pricing: Option<Pricing>,
45 #[serde(default)]
46 pub allow_substitutes: bool,
47 #[serde(default)]
48 pub product_details: ProductDetails,
49}
50
51impl BasketItem {
52 pub fn is_known_product(&self) -> bool {
66 self.product_details.attributes.is_some()
67 }
68}
69
70#[derive(Debug, Clone, Default, Deserialize)]
73pub struct ProductDetails {
74 #[serde(default)]
75 pub attributes: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Clone, Default, Deserialize)]
79pub struct LocalizedName {
80 #[serde(default)]
81 pub finnish: Option<String>,
82 #[serde(default)]
83 pub english: Option<String>,
84 #[serde(default)]
85 pub swedish: Option<String>,
86}
87
88impl LocalizedName {
89 pub fn best(&self) -> String {
90 self.finnish
91 .clone()
92 .or_else(|| self.english.clone())
93 .or_else(|| self.swedish.clone())
94 .unwrap_or_default()
95 }
96}
97
98pub const DEFAULT_UNIT: &str = "kpl";
101
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103pub struct AmountInfo {
104 pub amount: f64,
105 pub unit: String,
106}
107
108impl Default for AmountInfo {
109 fn default() -> Self {
110 Self {
111 amount: 0.0,
112 unit: DEFAULT_UNIT.into(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Pricing {
120 #[serde(default)]
121 pub price: Option<f64>,
122 #[serde(default)]
123 pub unit: Option<String>,
124 #[serde(default)]
126 pub is_approximate: bool,
127}
128
129#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
130#[serde(rename_all = "camelCase")]
131pub struct PriceSummary {
132 #[serde(default)]
133 pub items_sub_total: f64,
134 #[serde(default)]
135 pub grand_total: f64,
136}
137
138#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
139pub struct Store {
140 #[serde(default)]
141 pub id: String,
142 #[serde(default)]
143 pub name: String,
144}
145
146#[derive(Debug, Clone, Default, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct UserInfo {
152 #[serde(default)]
153 pub first_name: String,
154 #[serde(default)]
155 pub last_name: String,
156 #[serde(default)]
157 pub email: String,
158}
159
160impl UserInfo {
161 pub fn display(&self) -> Option<String> {
166 if self.email.is_empty() && self.first_name.is_empty() && self.last_name.is_empty() {
167 return None;
168 }
169 let name = format!("{} {}", self.first_name, self.last_name)
170 .trim()
171 .to_string();
172 Some(match (name.is_empty(), self.email.is_empty()) {
173 (false, false) => format!("{name} <{}>", self.email),
174 (false, true) => name,
175 (true, _) => self.email.clone(),
176 })
177 }
178}
179
180#[derive(Debug, Clone, Serialize)]
188#[serde(tag = "type", rename_all = "SCREAMING-KEBAB-CASE")]
189pub enum BasketEvent {
190 AddItem {
191 item: NewItem,
192 },
193 #[serde(rename_all = "camelCase")]
194 SetItemAmount {
195 item_id: String,
196 value: AmountInfo,
197 },
198 #[serde(rename_all = "camelCase")]
199 RemoveItem {
200 item_id: String,
201 },
202 ClearItems,
203}
204
205#[derive(Debug, Clone, Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct NewItem {
208 pub ean: String,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub local_store_id: Option<String>,
212 pub allow_substitutes: bool,
213 pub amount_info: AmountInfo,
214}
215
216#[derive(Debug, Clone, Serialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226pub struct CartView {
227 pub basket_id: String,
228 pub store: Store,
229 pub account: Option<String>,
232 pub items: Vec<CartItemView>,
233 pub totals: PriceSummary,
234}
235
236#[derive(Debug, Clone, Serialize, JsonSchema)]
237#[serde(rename_all = "camelCase")]
238pub struct CartItemView {
239 pub item_id: String,
241 pub ean: String,
242 pub name: String,
243 pub amount: f64,
244 pub unit: String,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub price: Option<f64>,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub price_unit: Option<String>,
249 pub price_is_approximate: bool,
251}
252
253impl From<Basket> for CartView {
254 fn from(b: Basket) -> Self {
255 Self {
256 basket_id: b.id,
257 store: b.store.unwrap_or_default(),
258 account: b.user_info.display(),
259 items: b
260 .items
261 .into_iter()
262 .map(|i| CartItemView {
263 item_id: i.id,
264 ean: i.ean,
265 name: i.name.best(),
266 amount: i.amount_info.amount,
267 unit: i.amount_info.unit,
268 price: i.pricing.as_ref().and_then(|p| p.price),
269 price_unit: i.pricing.as_ref().and_then(|p| p.unit.clone()),
270 price_is_approximate: i.pricing.as_ref().is_some_and(|p| p.is_approximate),
271 })
272 .collect(),
273 totals: b.price_summary,
274 }
275 }
276}
277
278#[derive(Debug, Clone, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct ProductSearchResponse {
285 #[serde(default)]
286 pub result: Vec<ProductHit>,
287 #[serde(default)]
288 pub total_hits: u64,
289}
290
291#[derive(Debug, Clone, Deserialize)]
292pub struct ProductHit {
293 pub product: SearchProduct,
294}
295
296#[derive(Debug, Clone, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct SearchProduct {
299 #[serde(default)]
300 pub ean: String,
301 #[serde(default)]
302 pub localized_name: LocalizedName,
303 #[serde(default)]
304 pub brand: Option<Brand>,
305 #[serde(default)]
309 pub is_available: bool,
310 #[serde(default)]
313 pub mobilescan: Option<MobileScan>,
314}
315
316#[derive(Debug, Clone, Deserialize)]
317pub struct Brand {
318 #[serde(default)]
319 pub name: Option<String>,
320}
321
322#[derive(Debug, Clone, Deserialize)]
323pub struct MobileScan {
324 #[serde(default)]
325 pub pricing: Option<MobileScanPricing>,
326}
327
328#[derive(Debug, Clone, Deserialize)]
329pub struct MobileScanPricing {
330 #[serde(default)]
331 pub normal: Option<NormalPrice>,
332}
333
334#[derive(Debug, Clone, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct NormalPrice {
337 #[serde(default)]
338 pub price: Option<f64>,
339 #[serde(default)]
340 pub unit: Option<String>,
341 #[serde(default)]
344 pub unit_price: Option<UnitPrice>,
345 #[serde(default)]
346 pub is_approximate: bool,
347}
348
349#[derive(Debug, Clone, Deserialize)]
350pub struct UnitPrice {
351 #[serde(default)]
352 pub value: Option<f64>,
353 #[serde(default)]
354 pub unit: Option<String>,
355}
356
357#[derive(Debug, Clone, Serialize, JsonSchema)]
358#[serde(rename_all = "camelCase")]
359pub struct ProductSearchView {
360 pub total_hits: u64,
362 pub results: Vec<ProductView>,
363}
364
365#[derive(Debug, Clone, Serialize, JsonSchema)]
366#[serde(rename_all = "camelCase")]
367pub struct ProductView {
368 pub ean: String,
370 pub name: String,
371 #[serde(skip_serializing_if = "Option::is_none")]
372 pub brand: Option<String>,
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub price: Option<f64>,
375 #[serde(skip_serializing_if = "Option::is_none")]
376 pub price_unit: Option<String>,
377 #[serde(skip_serializing_if = "Option::is_none")]
379 pub comparison_price: Option<String>,
380 pub price_is_approximate: bool,
382 pub is_available: bool,
384}
385
386impl From<ProductSearchResponse> for ProductSearchView {
387 fn from(r: ProductSearchResponse) -> Self {
388 Self {
389 total_hits: r.total_hits,
390 results: r.result.into_iter().map(|h| h.product.into()).collect(),
391 }
392 }
393}
394
395impl From<SearchProduct> for ProductView {
396 fn from(p: SearchProduct) -> Self {
397 let normal = p
398 .mobilescan
399 .as_ref()
400 .and_then(|m| m.pricing.as_ref())
401 .and_then(|p| p.normal.as_ref());
402 Self {
403 ean: p.ean,
404 name: p.localized_name.best(),
405 brand: p.brand.and_then(|b| b.name),
406 price: normal.and_then(|n| n.price),
407 price_unit: normal.and_then(|n| n.unit.clone()),
408 comparison_price: normal.and_then(|n| n.unit_price.as_ref()).and_then(|u| {
409 match (u.value, u.unit.as_deref()) {
410 (Some(value), Some(unit)) => Some(format!("{value} EUR/{unit}")),
411 _ => None,
412 }
413 }),
414 price_is_approximate: normal.is_some_and(|n| n.is_approximate),
415 is_available: p.is_available,
416 }
417 }
418}
419
420#[derive(Debug, Clone, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct StoreSearchResponse {
427 #[serde(default)]
428 pub results: Vec<StoreHit>,
429 #[serde(default)]
430 pub total_hits: u64,
431}
432
433#[derive(Debug, Clone, Deserialize)]
434#[serde(rename_all = "camelCase")]
435pub struct StoreHit {
436 #[serde(default)]
437 pub id: String,
438 #[serde(default)]
439 pub name: String,
440 #[serde(default)]
441 pub location: Option<String>,
442 #[serde(default)]
443 pub chain_name: Option<String>,
444 #[serde(default)]
447 pub is_web_store: bool,
448 #[serde(default)]
449 pub has_pickup: bool,
450 #[serde(default)]
451 pub has_home_delivery: bool,
452}
453
454#[derive(Debug, Clone, Serialize, JsonSchema)]
455#[serde(rename_all = "camelCase")]
456pub struct StoreSearchView {
457 pub total_hits: u64,
458 pub results: Vec<StoreView>,
459}
460
461#[derive(Debug, Clone, Serialize, JsonSchema)]
462#[serde(rename_all = "camelCase")]
463pub struct StoreView {
464 pub store_id: String,
466 pub name: String,
467 #[serde(skip_serializing_if = "Option::is_none")]
468 pub location: Option<String>,
469 #[serde(skip_serializing_if = "Option::is_none")]
470 pub chain: Option<String>,
471 pub is_web_store: bool,
473 pub has_pickup: bool,
474 pub has_home_delivery: bool,
475}
476
477impl From<StoreSearchResponse> for StoreSearchView {
478 fn from(r: StoreSearchResponse) -> Self {
479 Self {
480 total_hits: r.total_hits,
481 results: r
482 .results
483 .into_iter()
484 .map(|s| StoreView {
485 store_id: s.id,
486 name: s.name,
487 location: s.location,
488 chain: s.chain_name,
489 is_web_store: s.is_web_store,
490 has_pickup: s.has_pickup,
491 has_home_delivery: s.has_home_delivery,
492 })
493 .collect(),
494 }
495 }
496}
497
498#[derive(Debug, Clone, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct PersonalOffersResponse {
506 #[serde(default)]
507 pub offers: Vec<PersonalOffer>,
508}
509
510#[derive(Debug, Clone, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct PersonalOffer {
513 #[serde(default)]
514 pub localized_title: LocalizedName,
515 #[serde(default)]
516 pub pricing: Option<PersonalOfferPricing>,
517 #[serde(default)]
518 pub normal_pricing: Option<PersonalOfferNormalPricing>,
519 #[serde(default)]
520 pub products: Vec<PersonalOfferProduct>,
521 #[serde(default)]
524 pub remaining_quantity: Option<u32>,
525 #[serde(default)]
526 pub valid_until: Option<String>,
527}
528
529#[derive(Debug, Clone, Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct PersonalOfferPricing {
532 #[serde(default)]
533 pub price: Option<f64>,
534 #[serde(default)]
535 pub discount_percentage: Option<String>,
536 #[serde(default)]
540 pub unit: Option<OfferUnit>,
541}
542
543#[derive(Debug, Clone, Default, Deserialize)]
544pub struct OfferUnit {
545 #[serde(default)]
546 pub fi: Option<String>,
547}
548
549#[derive(Debug, Clone, Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct PersonalOfferNormalPricing {
552 #[serde(default)]
553 pub price: Option<f64>,
554}
555
556#[derive(Debug, Clone, Deserialize)]
557pub struct PersonalOfferProduct {
558 pub product: OfferProduct,
559}
560
561#[derive(Debug, Clone, Default, Deserialize)]
562#[serde(rename_all = "camelCase")]
563pub struct OfferProduct {
564 #[serde(default)]
565 pub ean: String,
566 #[serde(default)]
567 pub localized_name: LocalizedName,
568 #[serde(default)]
569 pub is_available: bool,
570}
571
572#[derive(Debug, Clone, Serialize, JsonSchema)]
573#[serde(rename_all = "camelCase")]
574pub struct PersonalOffersView {
575 pub store_id: String,
580 pub offers: Vec<PersonalOfferView>,
581}
582
583#[derive(Debug, Clone, Serialize, JsonSchema)]
584#[serde(rename_all = "camelCase")]
585pub struct PersonalOfferView {
586 pub title: String,
587 #[serde(skip_serializing_if = "Option::is_none")]
588 pub price: Option<f64>,
589 #[serde(skip_serializing_if = "Option::is_none")]
592 pub price_unit: Option<String>,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 pub normal_price: Option<f64>,
595 #[serde(skip_serializing_if = "Option::is_none")]
596 pub discount_percentage: Option<String>,
597 #[serde(skip_serializing_if = "Option::is_none")]
598 pub remaining_quantity: Option<u32>,
599 #[serde(skip_serializing_if = "Option::is_none")]
600 pub valid_until: Option<String>,
601 pub products: Vec<PersonalOfferProductView>,
607}
608
609#[derive(Debug, Clone, Serialize, JsonSchema)]
610#[serde(rename_all = "camelCase")]
611pub struct PersonalOfferProductView {
612 pub ean: String,
614 pub name: String,
615 pub is_available: bool,
617}
618
619impl From<PersonalOffer> for PersonalOfferView {
620 fn from(o: PersonalOffer) -> Self {
621 Self {
622 title: o.localized_title.best(),
623 price: o.pricing.as_ref().and_then(|p| p.price),
624 price_unit: o
625 .pricing
626 .as_ref()
627 .and_then(|p| p.unit.as_ref())
628 .and_then(|u| u.fi.clone()),
629 normal_price: o.normal_pricing.as_ref().and_then(|p| p.price),
630 discount_percentage: o.pricing.and_then(|p| p.discount_percentage),
631 remaining_quantity: o.remaining_quantity,
632 valid_until: o.valid_until,
633 products: o.products.into_iter().map(Into::into).collect(),
634 }
635 }
636}
637
638impl From<PersonalOfferProduct> for PersonalOfferProductView {
639 fn from(p: PersonalOfferProduct) -> Self {
640 Self {
641 ean: p.product.ean,
642 name: p.product.localized_name.best(),
643 is_available: p.product.is_available,
644 }
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 const LIVE: &str = r#"{
655 "schemaVersion": 5,
656 "id": "c0fa67a6-4b56-4dc6-9a7e-506c2b29b7cf",
657 "name": "Ostoskori",
658 "userInfo": {"firstName":"","lastName":"","email":"","phoneNumber":""},
659 "items": [{
660 "allowSubstitutes": true,
661 "amountInfo": {"amount": 1, "unit": "kpl"},
662 "ean": "2000818700008",
663 "id": "2000818700008",
664 "name": {"english":"Pirkka banana","finnish":"Pirkka banaani","swedish":"Pirkka banan"},
665 "pricing": {"isApproximate": true, "price": 0.3, "unit": "kg"},
666 "productDetails": {"attributes": {"ean": "2000818700008"}}
667 }],
668 "priceSummary": {"grandTotal": 0.3, "itemsSubTotal": 0.3,
669 "plussaSavings": {"total": 0, "type": "POTENTIAL"}},
670 "store": {"id": "N137", "name": "K-Citymarket Helsinki Ruoholahti"}
671 }"#;
672
673 #[test]
674 fn parses_a_live_basket_into_a_view() {
675 let view: CartView = serde_json::from_str::<Basket>(LIVE).unwrap().into();
676 assert_eq!(view.store.id, "N137");
677 assert_eq!(view.totals.grand_total, 0.3);
678 assert_eq!(view.items.len(), 1);
679
680 let item = &view.items[0];
681 assert_eq!(item.item_id, "2000818700008");
682 assert_eq!(item.name, "Pirkka banaani");
683 assert_eq!(item.amount, 1.0);
684 assert_eq!(item.unit, "kpl");
685 assert!(item.price_is_approximate);
686 }
687
688 #[test]
691 fn anonymous_basket_reports_no_account() {
692 let view: CartView = serde_json::from_str::<Basket>(LIVE).unwrap().into();
693 assert_eq!(view.account, None);
694 }
695
696 #[test]
697 fn logged_in_basket_reports_the_account() {
698 let json = LIVE.replace(
699 r#""firstName":"","lastName":"","email":""#,
700 r#""firstName":"Niko","lastName":"Savola","email":"n@example.com"#,
701 );
702 let view: CartView = serde_json::from_str::<Basket>(&json).unwrap().into();
703 assert_eq!(view.account.as_deref(), Some("Niko Savola <n@example.com>"));
704 }
705
706 #[test]
708 fn events_serialise_to_the_shapes_the_bundle_expects() {
709 let add = BasketEvent::AddItem {
710 item: NewItem {
711 ean: "2000818700008".into(),
712 local_store_id: None,
713 allow_substitutes: true,
714 amount_info: AmountInfo {
715 amount: 2.0,
716 unit: "kpl".into(),
717 },
718 },
719 };
720 assert_eq!(
721 serde_json::to_value(&add).unwrap(),
722 serde_json::json!({
723 "type": "ADD-ITEM",
724 "item": {
725 "ean": "2000818700008",
726 "allowSubstitutes": true,
727 "amountInfo": {"amount": 2.0, "unit": "kpl"}
728 }
729 })
730 );
731
732 let set = BasketEvent::SetItemAmount {
733 item_id: "x".into(),
734 value: AmountInfo {
735 amount: 3.0,
736 unit: "kpl".into(),
737 },
738 };
739 assert_eq!(
740 serde_json::to_value(&set).unwrap(),
741 serde_json::json!({
742 "type": "SET-ITEM-AMOUNT",
743 "itemId": "x",
744 "value": {"amount": 3.0, "unit": "kpl"}
745 })
746 );
747
748 assert_eq!(
749 serde_json::to_value(BasketEvent::RemoveItem {
750 item_id: "x".into()
751 })
752 .unwrap(),
753 serde_json::json!({"type": "REMOVE-ITEM", "itemId": "x"})
754 );
755 assert_eq!(
756 serde_json::to_value(BasketEvent::ClearItems).unwrap(),
757 serde_json::json!({"type": "CLEAR-ITEMS"})
758 );
759 }
760
761 #[test]
764 fn local_store_id_is_omitted_when_absent() {
765 let item = NewItem {
766 ean: "1".into(),
767 local_store_id: None,
768 allow_substitutes: false,
769 amount_info: AmountInfo::default(),
770 };
771 let v = serde_json::to_value(&item).unwrap();
772 assert!(!v.as_object().unwrap().contains_key("localStoreId"));
773
774 let item = NewItem {
775 local_store_id: Some("N137".into()),
776 ..item
777 };
778 assert_eq!(serde_json::to_value(&item).unwrap()["localStoreId"], "N137");
779 }
780}