1use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::event::{Alphabet, Event, EventBuilder, Kind, Tag};
47use crate::key::PublicKey;
48use crate::util::json::JsonUtil;
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct ShippingMethod {
53 pub id: String,
55 #[serde(skip_serializing_if = "Option::is_none", default)]
57 pub name: Option<String>,
58 pub cost: f64,
60 #[serde(default)]
62 pub regions: Vec<String>,
63}
64
65impl ShippingMethod {
66 #[must_use]
68 pub fn new<S>(id: S, cost: f64) -> Self
69 where
70 S: Into<String>,
71 {
72 Self {
73 id: id.into(),
74 name: None,
75 cost,
76 regions: Vec::new(),
77 }
78 }
79
80 #[must_use]
82 pub fn name<S>(mut self, name: S) -> Self
83 where
84 S: Into<String>,
85 {
86 self.name = Some(name.into());
87 self
88 }
89
90 #[must_use]
92 pub fn regions(mut self, regions: Vec<String>) -> Self {
93 self.regions = regions;
94 self
95 }
96
97 #[must_use]
99 pub fn to_shipping_cost(&self) -> ShippingCost {
100 ShippingCost {
101 id: self.id.clone(),
102 cost: self.cost,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct ShippingCost {
110 pub id: String,
112 pub cost: f64,
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct StallData {
119 pub id: String,
121 pub name: String,
123 #[serde(skip_serializing_if = "Option::is_none", default)]
125 pub description: Option<String>,
126 pub currency: String,
128 #[serde(default)]
130 pub shipping: Vec<ShippingMethod>,
131}
132
133impl StallData {
134 #[must_use]
136 pub fn new<S>(id: S, name: S, currency: S) -> Self
137 where
138 S: Into<String>,
139 {
140 Self {
141 id: id.into(),
142 name: name.into(),
143 description: None,
144 currency: currency.into(),
145 shipping: Vec::new(),
146 }
147 }
148
149 #[must_use]
151 pub fn description<S>(mut self, description: S) -> Self
152 where
153 S: Into<String>,
154 {
155 self.description = Some(description.into());
156 self
157 }
158
159 #[must_use]
161 pub fn shipping(mut self, shipping: Vec<ShippingMethod>) -> Self {
162 self.shipping = shipping;
163 self
164 }
165
166 pub fn to_event_builder(&self) -> Result<EventBuilder, serde_json::Error> {
173 let content = self.try_to_json()?;
174 Ok(EventBuilder::new(Kind::MARKETPLACE_STALL, content).tag(Tag::d(self.id.clone())))
175 }
176
177 pub fn from_event(event: &Event) -> Result<Self, MarketplaceError> {
184 expect_kind(event, Kind::MARKETPLACE_STALL)?;
185 Ok(Self::from_json(&event.content)?)
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct ProductData {
195 pub id: String,
197 pub stall_id: String,
199 pub name: String,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
203 pub description: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub images: Option<Vec<String>>,
207 pub currency: String,
209 pub price: f64,
211 pub quantity: Option<u64>,
213 #[serde(skip_serializing_if = "Option::is_none", default)]
215 pub specs: Option<Vec<Vec<String>>>,
216 #[serde(default)]
218 pub shipping: Vec<ShippingCost>,
219 #[serde(skip_serializing, default)]
221 pub categories: Option<Vec<String>>,
222}
223
224impl ProductData {
225 #[must_use]
227 pub fn new<S>(id: S, stall_id: S, name: S, currency: S) -> Self
228 where
229 S: Into<String>,
230 {
231 Self {
232 id: id.into(),
233 stall_id: stall_id.into(),
234 name: name.into(),
235 description: None,
236 images: None,
237 currency: currency.into(),
238 price: 0.0,
239 quantity: Some(1),
240 specs: None,
241 shipping: Vec::new(),
242 categories: None,
243 }
244 }
245
246 #[must_use]
248 pub fn description<S>(mut self, description: S) -> Self
249 where
250 S: Into<String>,
251 {
252 self.description = Some(description.into());
253 self
254 }
255
256 #[must_use]
258 pub fn images(mut self, images: Vec<String>) -> Self {
259 self.images = Some(images);
260 self
261 }
262
263 #[must_use]
265 pub const fn price(mut self, price: f64) -> Self {
266 self.price = price;
267 self
268 }
269
270 #[must_use]
272 pub const fn quantity(mut self, quantity: Option<u64>) -> Self {
273 self.quantity = quantity;
274 self
275 }
276
277 #[must_use]
280 pub fn specs(mut self, specs: Vec<Vec<String>>) -> Self {
281 let valid: Vec<Vec<String>> = specs.into_iter().filter(|s| s.len() == 2).collect();
282 self.specs = Some(valid);
283 self
284 }
285
286 #[must_use]
288 pub fn shipping(mut self, shipping: Vec<ShippingCost>) -> Self {
289 self.shipping = shipping;
290 self
291 }
292
293 #[must_use]
295 pub fn categories(mut self, categories: Vec<String>) -> Self {
296 self.categories = Some(categories);
297 self
298 }
299
300 pub fn to_event_builder(&self) -> Result<EventBuilder, serde_json::Error> {
309 let content = self.try_to_json()?;
310 let mut builder =
311 EventBuilder::new(Kind::MARKETPLACE_PRODUCT, content).tag(Tag::d(self.id.clone()));
312 if let Some(categories) = &self.categories {
313 for category in categories {
314 builder = builder.tag(Tag::t(category));
315 }
316 }
317 Ok(builder)
318 }
319
320 pub fn from_event(event: &Event) -> Result<Self, MarketplaceError> {
330 expect_kind(event, Kind::MARKETPLACE_PRODUCT)?;
331 let mut product = Self::from_json(&event.content)?;
332 product.categories = collect_hashtags(event);
333 Ok(product)
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342pub struct AuctionData {
343 pub id: String,
345 pub stall_id: String,
347 pub name: String,
349 #[serde(skip_serializing_if = "Option::is_none", default)]
351 pub description: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none", default)]
354 pub images: Option<Vec<String>>,
355 pub starting_bid: u64,
357 #[serde(skip_serializing_if = "Option::is_none", default)]
359 pub start_date: Option<u64>,
360 pub duration: u64,
362 #[serde(skip_serializing_if = "Option::is_none", default)]
364 pub specs: Option<Vec<Vec<String>>>,
365 #[serde(default)]
367 pub shipping: Vec<ShippingCost>,
368}
369
370impl AuctionData {
371 #[must_use]
373 pub fn new<S>(id: S, stall_id: S, name: S, starting_bid: u64, duration: u64) -> Self
374 where
375 S: Into<String>,
376 {
377 Self {
378 id: id.into(),
379 stall_id: stall_id.into(),
380 name: name.into(),
381 description: None,
382 images: None,
383 starting_bid,
384 start_date: None,
385 duration,
386 specs: None,
387 shipping: Vec::new(),
388 }
389 }
390
391 pub fn to_event_builder(&self) -> Result<EventBuilder, serde_json::Error> {
397 let content = self.try_to_json()?;
398 Ok(EventBuilder::new(Kind::MARKETPLACE_AUCTION, content).tag(Tag::d(self.id.clone())))
399 }
400
401 pub fn from_event(event: &Event) -> Result<Self, MarketplaceError> {
408 expect_kind(event, Kind::MARKETPLACE_AUCTION)?;
409 Ok(Self::from_json(&event.content)?)
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct CustomerOrderItem {
416 pub product_id: String,
418 pub quantity: u64,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct CustomerContact {
425 #[serde(skip_serializing_if = "Option::is_none", default)]
427 pub nostr: Option<PublicKey>,
428 #[serde(skip_serializing_if = "Option::is_none", default)]
430 pub phone: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none", default)]
433 pub email: Option<String>,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438pub struct CustomerOrder {
439 pub id: String,
441 #[serde(rename = "type")]
443 pub message_type: u8,
444 #[serde(skip_serializing_if = "Option::is_none", default)]
446 pub name: Option<String>,
447 #[serde(skip_serializing_if = "Option::is_none", default)]
449 pub address: Option<String>,
450 #[serde(skip_serializing_if = "Option::is_none", default)]
452 pub message: Option<String>,
453 pub contact: CustomerContact,
455 pub items: Vec<CustomerOrderItem>,
457 pub shipping_id: String,
459}
460
461impl CustomerOrder {
462 #[must_use]
464 pub fn new<S>(
465 id: S,
466 contact: CustomerContact,
467 items: Vec<CustomerOrderItem>,
468 shipping_id: S,
469 ) -> Self
470 where
471 S: Into<String>,
472 {
473 Self {
474 id: id.into(),
475 message_type: 0,
476 name: None,
477 address: None,
478 message: None,
479 contact,
480 items,
481 shipping_id: shipping_id.into(),
482 }
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct PaymentOption {
489 #[serde(rename = "type")]
491 pub option_type: String,
492 pub link: String,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct MerchantPaymentRequest {
499 pub id: String,
501 #[serde(rename = "type")]
503 pub message_type: u8,
504 #[serde(skip_serializing_if = "Option::is_none", default)]
506 pub message: Option<String>,
507 pub payment_options: Vec<PaymentOption>,
509}
510
511impl MerchantPaymentRequest {
512 #[must_use]
514 pub fn new<S>(id: S, payment_options: Vec<PaymentOption>) -> Self
515 where
516 S: Into<String>,
517 {
518 Self {
519 id: id.into(),
520 message_type: 1,
521 message: None,
522 payment_options,
523 }
524 }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct MerchantVerifyPayment {
530 pub id: String,
532 #[serde(rename = "type")]
534 pub message_type: u8,
535 #[serde(skip_serializing_if = "Option::is_none", default)]
537 pub message: Option<String>,
538 pub paid: bool,
540 pub shipped: bool,
542}
543
544impl MerchantVerifyPayment {
545 #[must_use]
547 pub fn new<S>(id: S, paid: bool, shipped: bool) -> Self
548 where
549 S: Into<String>,
550 {
551 Self {
552 id: id.into(),
553 message_type: 2,
554 message: None,
555 paid,
556 shipped,
557 }
558 }
559}
560
561#[derive(Debug, Error)]
563#[non_exhaustive]
564#[allow(
565 variant_size_differences,
566 reason = "the serde_json::Error source is already boxed to an 8-byte pointer (the smallest sound representation), but still trips the heuristic against the small UnexpectedKind variant — mirrors nip18::RepostError"
567)]
568pub enum MarketplaceError {
569 #[error("expected kind {expected}, got {got}")]
571 UnexpectedKind {
572 expected: u16,
574 got: u16,
576 },
577 #[error("invalid marketplace JSON content: {0}")]
582 InvalidContent(#[source] Box<serde_json::Error>),
583}
584
585impl From<serde_json::Error> for MarketplaceError {
586 fn from(value: serde_json::Error) -> Self {
587 Self::InvalidContent(Box::new(value))
588 }
589}
590
591fn expect_kind(event: &Event, expected: Kind) -> Result<(), MarketplaceError> {
592 if event.kind == expected {
593 Ok(())
594 } else {
595 Err(MarketplaceError::UnexpectedKind {
596 expected: expected.as_u16(),
597 got: event.kind.as_u16(),
598 })
599 }
600}
601
602fn collect_hashtags(event: &Event) -> Option<Vec<String>> {
603 let hashtags: Vec<String> = event
604 .tags
605 .find_letter(Alphabet::T)
606 .filter_map(Tag::content)
607 .map(str::to_owned)
608 .collect();
609 if hashtags.is_empty() {
610 None
611 } else {
612 Some(hashtags)
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use crate::Keys;
620
621 fn keys() -> Keys {
622 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
623 }
624
625 #[test]
626 fn stall_json_is_byte_compatible_with_upstream() {
627 let stall = StallData::new("123", "Test Stall", "USD")
628 .description("Test Description")
629 .shipping(vec![ShippingMethod::new("123", 5.0).name("default")]);
630 assert_eq!(
631 stall.try_to_json().unwrap(),
632 r#"{"id":"123","name":"Test Stall","description":"Test Description","currency":"USD","shipping":[{"id":"123","name":"default","cost":5.0,"regions":[]}]}"#
633 );
634 }
635
636 #[test]
637 fn stall_round_trip_through_event() {
638 let stall = StallData::new("s1", "Stall", "USD").shipping(vec![
639 ShippingMethod::new("z1", 2.5).regions(vec!["EU".to_owned()]),
640 ]);
641 let event = stall
642 .to_event_builder()
643 .unwrap()
644 .sign_with_keys(&keys())
645 .unwrap();
646 assert_eq!(event.kind, Kind::MARKETPLACE_STALL);
647 assert_eq!(event.tags.identifier(), Some("s1"));
648 assert_eq!(StallData::from_event(&event).unwrap(), stall);
649 }
650
651 #[test]
652 fn product_is_addressable_by_product_id_not_stall_id() {
653 let product = ProductData::new("prod-9", "stall-1", "Widget", "USD").price(9.99);
656 let event = product
657 .to_event_builder()
658 .unwrap()
659 .sign_with_keys(&keys())
660 .unwrap();
661 assert_eq!(event.kind, Kind::MARKETPLACE_PRODUCT);
662 assert_eq!(event.tags.identifier(), Some("prod-9"));
663 }
664
665 #[test]
666 fn product_round_trip_with_categories_from_tags() {
667 let product = ProductData::new("p1", "s1", "Thing", "SAT")
668 .price(1000.0)
669 .quantity(Some(3))
670 .images(vec!["https://img.example/x.png".to_owned()])
671 .specs(vec![vec!["size".to_owned(), "M".to_owned()]])
672 .categories(vec!["electronics".to_owned(), "phones".to_owned()]);
673 let event = product
674 .to_event_builder()
675 .unwrap()
676 .sign_with_keys(&keys())
677 .unwrap();
678
679 assert!(!event.content.contains("categories"));
681 let parsed = ProductData::from_event(&event).unwrap();
683 assert_eq!(parsed, product);
684 }
685
686 #[test]
687 fn product_unlimited_quantity_serializes_as_null() {
688 let product = ProductData::new("p1", "s1", "Service", "USD").quantity(None);
689 assert!(
690 product
691 .try_to_json()
692 .unwrap()
693 .contains(r#""quantity":null"#)
694 );
695 }
696
697 #[test]
698 fn auction_round_trip_through_event() {
699 let auction = AuctionData::new("a1", "s1", "Rare Item", 100, 86_400);
700 let event = auction
701 .to_event_builder()
702 .unwrap()
703 .sign_with_keys(&keys())
704 .unwrap();
705 assert_eq!(event.kind, Kind::MARKETPLACE_AUCTION);
706 assert_eq!(event.tags.identifier(), Some("a1"));
707 assert_eq!(AuctionData::from_event(&event).unwrap(), auction);
708 }
709
710 #[test]
711 fn wrong_kind_is_rejected() {
712 let event = EventBuilder::text_note("nope")
713 .sign_with_keys(&keys())
714 .unwrap();
715 assert!(matches!(
716 StallData::from_event(&event).unwrap_err(),
717 MarketplaceError::UnexpectedKind {
718 expected: 30_017,
719 got: 1
720 }
721 ));
722 }
723
724 #[test]
725 fn order_uses_product_id_field() {
726 let order = CustomerOrder::new(
727 "o1",
728 CustomerContact {
729 nostr: None,
730 phone: None,
731 email: Some("a@b.c".to_owned()),
732 },
733 vec![CustomerOrderItem {
734 product_id: "p1".to_owned(),
735 quantity: 2,
736 }],
737 "z1",
738 );
739 let json = order.try_to_json().unwrap();
740 assert!(json.contains(r#""type":0"#));
741 assert!(json.contains(r#""product_id":"p1""#));
742 assert_eq!(CustomerOrder::from_json(&json).unwrap(), order);
743 }
744
745 #[test]
746 fn payment_messages_carry_type_discriminants() {
747 let req = MerchantPaymentRequest::new(
748 "o1",
749 vec![PaymentOption {
750 option_type: "ln".to_owned(),
751 link: "lnbc...".to_owned(),
752 }],
753 );
754 assert!(req.try_to_json().unwrap().contains(r#""type":1"#));
755
756 let verify = MerchantVerifyPayment::new("o1", true, false);
757 assert!(verify.try_to_json().unwrap().contains(r#""type":2"#));
758 }
759}