lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 927e08fac32a77b9e104e6d2203b3c0b61dc28c3
parent 4577c1e9a9bea93bd50922994be3e0bf9bf4abc1
Author: triesap <tyson@radroots.org>
Date:   Fri,  2 Jan 2026 18:02:54 +0000

listing: encode bins and discounts in listing tags


- Replace quantity/price tags with radroots:bin, radroots:price, and radroots:primary_bin
- Serialize discounts as radroots:discount payloads and drop legacy price-discount-* tags
- Update trade listing codec, pricing, and order models to use bin_id/bin_count
- Regenerate TS bindings and adjust tests for bin-based listing roundtrips

Diffstat:
Mevents-codec/src/farm/mod.rs | 33++++++++++++++++++++++++++++++---
Mevents-codec/src/listing/tags.rs | 211++++++++++++++++++++++++++++++++++++-------------------------------------------
Mevents-codec/tests/listing.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mevents/bindings/ts/package.json | 5++---
Mevents/bindings/ts/src/types.ts | 2++
Mtrade/bindings/ts/package.json | 5++---
Mtrade/bindings/ts/src/types.ts | 22+++++++++++-----------
Mtrade/src/listing/codec.rs | 396+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mtrade/src/listing/order.rs | 18+++++-------------
Mtrade/src/listing/price_ext.rs | 95++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mtrade/src/listing/stage/order.rs | 17+++++++----------
Mtrade/src/listing/validation.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
12 files changed, 608 insertions(+), 431 deletions(-)

diff --git a/events-codec/src/farm/mod.rs b/events-codec/src/farm/mod.rs @@ -20,7 +20,13 @@ mod tests { farm_plots_list_set_from_plots, member_of_farms_list_set, }; - use radroots_events::listing::{RadrootsListing, RadrootsListingFarmRef, RadrootsListingProduct}; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_events::listing::{ + RadrootsListing, RadrootsListingBin, RadrootsListingFarmRef, RadrootsListingProduct, + }; #[test] fn farm_tags_include_required_fields() { @@ -149,8 +155,29 @@ mod tests { profile: None, year: None, }, - quantities: vec![], - prices: vec![], + primary_bin_id: "bin-1".to_string(), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".to_string(), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::Each, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::Each, + ), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], discounts: None, inventory_available: None, availability: None, diff --git a/events-codec/src/listing/tags.rs b/events-codec/src/listing/tags.rs @@ -5,14 +5,10 @@ use alloc::{format, string::{String, ToString}, vec, vec::Vec}; use core::cmp; -use radroots_core::RadrootsCoreQuantityPrice; -#[cfg(feature = "serde_json")] -use radroots_core::{ - RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, -}; +use radroots_core::{RadrootsCoreDiscount, RadrootsCoreMoney}; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, RadrootsListingFarmRef, - RadrootsListingDiscount, RadrootsListingImage, RadrootsListingLocation, RadrootsListingQuantity, + RadrootsListingBin, RadrootsListingImage, RadrootsListingLocation, RadrootsListingStatus, }; use radroots_events::kinds::KIND_FARM; @@ -20,9 +16,11 @@ use radroots_events::tags::TAG_D; use crate::error::EventEncodeError; -const TAG_QUANTITY: &str = "quantity"; const TAG_PRICE: &str = "price"; -const TAG_PRICE_DISCOUNT_PREFIX: &str = "price-discount-"; +const TAG_RADROOTS_BIN: &str = "radroots:bin"; +const TAG_RADROOTS_PRICE: &str = "radroots:price"; +const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; +const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; const TAG_LOCATION: &str = "location"; const TAG_IMAGE: &str = "image"; const TAG_GEOHASH: &str = "g"; @@ -96,6 +94,12 @@ pub fn listing_tags_with_options( if d_tag.is_empty() { return Err(EventEncodeError::EmptyRequiredField("d")); } + if listing.primary_bin_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("primary_bin_id")); + } + if listing.bins.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("bins")); + } let mut tags: Vec<Vec<String>> = Vec::new(); tags.push(vec![TAG_D.to_string(), d_tag.to_string()]); @@ -124,19 +128,25 @@ pub fn listing_tags_with_options( push_tag_value(&mut tags, "year", year); } - for quantity in &listing.quantities { - tags.push(tag_listing_quantity(quantity)); + tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.to_string(), listing.primary_bin_id.clone()]); + + let mut bins: Vec<&RadrootsListingBin> = listing.bins.iter().collect(); + if let Some(pos) = bins.iter().position(|bin| bin.bin_id == listing.primary_bin_id) { + let primary = bins.remove(pos); + bins.insert(0, primary); } - for price in &listing.prices { - tags.push(tag_listing_price_generic(price)); - tags.push(tag_listing_price(price)); + for bin in bins { + tags.push(tag_listing_bin(bin)?); + tags.push(tag_listing_price(bin)?); + let total = bin_total_price(bin)?; + tags.push(tag_listing_price_generic(&total)); } if let Some(discounts) = &listing.discounts { for discount in discounts { - let (kind, payload) = discount_tag_parts(discount)?; - tags.push(vec![format!("{TAG_PRICE_DISCOUNT_PREFIX}{kind}"), payload]); + let payload = discount_tag_payload(discount)?; + tags.push(vec![TAG_RADROOTS_DISCOUNT.to_string(), payload]); } } @@ -234,48 +244,86 @@ fn push_farm_tags( Ok(()) } -fn tag_listing_quantity(quantity: &RadrootsListingQuantity) -> Vec<String> { - let mut tag = Vec::with_capacity(5); - tag.push(TAG_QUANTITY.to_string()); - tag.push(quantity.value.amount.to_string()); - tag.push(quantity.value.unit.code().to_string()); - let label = quantity - .label - .as_deref() - .and_then(clean_value) - .or_else(|| quantity.value.label.as_deref().and_then(clean_value)); - if let Some(label) = label { - tag.push(label); - } - if let Some(count) = quantity.count { - tag.push(count.to_string()); +fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { + if bin.bin_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("bin_id")); + } + let unit = bin.quantity.unit; + if unit != unit.canonical_unit() { + return Err(EventEncodeError::EmptyRequiredField("bin.quantity")); + } + let mut tag = Vec::with_capacity(7); + tag.push(TAG_RADROOTS_BIN.to_string()); + tag.push(bin.bin_id.clone()); + tag.push(bin.quantity.amount.to_string()); + tag.push(unit.code().to_string()); + match (bin.display_amount.as_ref(), bin.display_unit) { + (Some(amount), Some(unit)) => { + tag.push(amount.to_string()); + tag.push(unit.code().to_string()); + if let Some(label) = bin + .display_label + .as_deref() + .and_then(clean_value) + .or_else(|| bin.quantity.label.as_deref().and_then(clean_value)) + { + tag.push(label); + } + } + (None, None) => {} + (None, Some(_)) => { + return Err(EventEncodeError::EmptyRequiredField("bin.display_amount")); + } + (Some(_), None) => { + return Err(EventEncodeError::EmptyRequiredField("bin.display_unit")); + } } - tag + Ok(tag) } -fn tag_listing_price(price: &RadrootsCoreQuantityPrice) -> Vec<String> { - let mut tag = Vec::with_capacity(6); - tag.push(TAG_PRICE.to_string()); +fn tag_listing_price(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { + if bin.bin_id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("bin_id")); + } + let price = &bin.price_per_canonical_unit; + if !price.is_price_per_canonical_unit() { + return Err(EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")); + } + let mut tag = Vec::with_capacity(8); + tag.push(TAG_RADROOTS_PRICE.to_string()); + tag.push(bin.bin_id.clone()); tag.push(price.amount.amount.to_string()); - tag.push(price.amount.currency.as_str().to_ascii_lowercase()); + tag.push(price.amount.currency.as_str().to_string()); tag.push(price.quantity.amount.to_string()); tag.push(price.quantity.unit.code().to_string()); - if let Some(label) = price - .quantity - .label - .as_deref() - .and_then(clean_value) - { - tag.push(label); + match (&bin.display_price, bin.display_price_unit) { + (Some(price_display), Some(unit)) => { + if price_display.currency != price.amount.currency { + return Err(EventEncodeError::EmptyRequiredField("bin.display_price")); + } + tag.push(price_display.amount.to_string()); + tag.push(unit.code().to_string()); + } + (None, None) => {} + (None, Some(_)) => return Err(EventEncodeError::EmptyRequiredField("bin.display_price")), + (Some(_), None) => { + return Err(EventEncodeError::EmptyRequiredField("bin.display_price_unit")); + } } - tag + Ok(tag) } -fn tag_listing_price_generic(price: &RadrootsCoreQuantityPrice) -> Vec<String> { +fn bin_total_price(bin: &RadrootsListingBin) -> Result<RadrootsCoreMoney, EventEncodeError> { + bin.price_per_canonical_unit + .try_cost_for_quantity_in(&bin.quantity) + .map_err(|_| EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")) +} + +fn tag_listing_price_generic(price: &RadrootsCoreMoney) -> Vec<String> { let mut tag = Vec::with_capacity(4); tag.push(TAG_PRICE.to_string()); - tag.push(price.amount.amount.to_string()); - tag.push(price.amount.currency.as_str().to_ascii_lowercase()); + tag.push(price.amount.to_string()); + tag.push(price.currency.as_str().to_string()); tag } @@ -481,48 +529,10 @@ fn clean_value(value: &str) -> Option<String> { } } -fn discount_tag_parts( - discount: &RadrootsListingDiscount, -) -> Result<(&'static str, String), EventEncodeError> { +fn discount_tag_payload(discount: &RadrootsCoreDiscount) -> Result<String, EventEncodeError> { #[cfg(feature = "serde_json")] { - let (kind, payload) = match discount { - RadrootsListingDiscount::Quantity { - ref_quantity, - threshold, - value, - } => ( - "quantity", - serde_json::to_string(&QuantityDiscountPayload { - ref_quantity: ref_quantity.clone(), - threshold: threshold.clone(), - value: value.clone(), - }), - ), - RadrootsListingDiscount::Mass { threshold, value } => ( - "mass", - serde_json::to_string(&MassDiscountPayload { - threshold: threshold.clone(), - value: value.clone(), - }), - ), - RadrootsListingDiscount::Subtotal { threshold, value } => ( - "subtotal", - serde_json::to_string(&SubtotalDiscountPayload { - threshold: threshold.clone(), - value: value.clone(), - }), - ), - RadrootsListingDiscount::Total { total_min, value } => ( - "total", - serde_json::to_string(&TotalDiscountPayload { - total_min: total_min.clone(), - value: value.clone(), - }), - ), - }; - let payload = payload.map_err(|_| EventEncodeError::Json)?; - return Ok((kind, payload)); + return serde_json::to_string(discount).map_err(|_| EventEncodeError::Json); } #[cfg(not(feature = "serde_json"))] { @@ -538,32 +548,3 @@ fn status_as_str(status: &RadrootsListingStatus) -> &str { RadrootsListingStatus::Other { value } => value.as_str(), } } - -#[cfg(feature = "serde_json")] -#[derive(serde::Serialize, Clone)] -struct QuantityDiscountPayload { - ref_quantity: String, - threshold: RadrootsCoreQuantity, - value: RadrootsCoreMoney, -} - -#[cfg(feature = "serde_json")] -#[derive(serde::Serialize, Clone)] -struct MassDiscountPayload { - threshold: RadrootsCoreQuantity, - value: RadrootsCoreMoney, -} - -#[cfg(feature = "serde_json")] -#[derive(serde::Serialize, Clone)] -struct SubtotalDiscountPayload { - threshold: RadrootsCoreMoney, - value: RadrootsCoreDiscountValue, -} - -#[cfg(feature = "serde_json")] -#[derive(serde::Serialize, Clone)] -struct TotalDiscountPayload { - total_min: RadrootsCoreMoney, - value: RadrootsCorePercent, -} diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs @@ -1,16 +1,17 @@ #![cfg(feature = "serde_json")] use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, + RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, + RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_events::{ kinds::{KIND_LISTING, KIND_POST}, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingDiscount, RadrootsListingFarmRef, RadrootsListingImage, + RadrootsListingBin, RadrootsListingFarmRef, RadrootsListingImage, RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct, - RadrootsListingQuantity, RadrootsListingStatus, + RadrootsListingStatus, }, }; use radroots_events::tags::TAG_D; @@ -44,12 +45,17 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { profile: None, year: None, }, - quantities: vec![RadrootsListingQuantity { - value: quantity, - label: None, - count: Some(1), + primary_bin_id: "bin-1".to_string(), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".to_string(), + quantity, + price_per_canonical_unit: price, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, }], - prices: vec![price], discounts: None, inventory_available: None, availability: None, @@ -60,14 +66,10 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { } fn sample_listing_full(d_tag: &str) -> RadrootsListing { - let qty_amount = RadrootsCoreDecimal::from_str("1").unwrap(); - let price_amount = RadrootsCoreDecimal::from_str("24.50").unwrap(); - let discount_threshold = RadrootsCoreDecimal::from_str("10").unwrap(); - let discount_amount = RadrootsCoreDecimal::from_str("20").unwrap(); - - let quantity = RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassLb).with_label("bag"); - let price_quantity = - RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassLb).with_label("bag"); + let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap(); + let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap(); + let display_qty = RadrootsCoreDecimal::from_str("1").unwrap(); + let display_price = RadrootsCoreDecimal::from_str("10").unwrap(); RadrootsListing { d_tag: d_tag.to_string(), @@ -86,19 +88,33 @@ fn sample_listing_full(d_tag: &str) -> RadrootsListing { profile: Some("standard".to_string()), year: Some("2024".to_string()), }, - quantities: vec![RadrootsListingQuantity { - value: quantity, - label: None, - count: Some(120), + primary_bin_id: "bin-1".to_string(), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".to_string(), + quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + ), + display_amount: Some(display_qty), + display_unit: Some(RadrootsCoreUnit::MassKg), + display_label: Some("bag".to_string()), + display_price: Some(RadrootsCoreMoney::new( + display_price, + RadrootsCoreCurrency::USD, + )), + display_price_unit: Some(RadrootsCoreUnit::MassKg), }], - prices: vec![RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD), - price_quantity, - )], - discounts: Some(vec![RadrootsListingDiscount::Quantity { - ref_quantity: "bag".to_string(), - threshold: RadrootsCoreQuantity::new(discount_threshold, RadrootsCoreUnit::MassLb), - value: RadrootsCoreMoney::new(discount_amount, RadrootsCoreCurrency::USD), + discounts: Some(vec![RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::Bin, + threshold: RadrootsCoreDiscountThreshold::BinCount { + bin_id: "bin-1".to_string(), + min: 5, + }, + value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( + RadrootsCoreDecimal::from_str("2").unwrap(), + RadrootsCoreCurrency::USD, + )), }]), inventory_available: None, availability: None, @@ -138,8 +154,8 @@ fn listing_roundtrip_from_event() { assert_eq!(decoded.d_tag, listing.d_tag); assert_eq!(decoded.product.key, listing.product.key); assert_eq!(decoded.product.title, listing.product.title); - assert_eq!(decoded.quantities.len(), listing.quantities.len()); - assert_eq!(decoded.prices.len(), listing.prices.len()); + assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); + assert_eq!(decoded.bins.len(), listing.bins.len()); } #[test] @@ -216,29 +232,51 @@ fn listing_build_tags_includes_listing_fields() { && t.get(1).map(|s| s.as_str()) == Some("Widget") })); - let qty_tag = tags + let primary_tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:primary_bin")) + .expect("primary bin tag"); + assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1")); + + let bin_tag = tags .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some("quantity")) - .expect("quantity tag"); - assert_eq!(qty_tag.get(2).map(|s| s.as_str()), Some("lb")); - assert_eq!(qty_tag.get(3).map(|s| s.as_str()), Some("bag")); - assert_eq!(qty_tag.get(4).map(|s| s.as_str()), Some("120")); + .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:bin")) + .expect("bin tag"); + assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1")); + assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000")); + assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g")); + assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1")); + assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg")); + assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag")); let price_tag = tags .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some("price")) - .expect("price tag"); - assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("usd")); - assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("lb")); - assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("bag")); + .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:price")) + .expect("radroots price tag"); + assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1")); + assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01")); + assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD")); + assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1")); + assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g")); + assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10")); + assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg")); + + let generic_price_tag = tags + .iter() + .find(|t| { + t.get(0).map(|s| s.as_str()) == Some("price") + && t.get(1).map(|s| s.as_str()) == Some("10") + }) + .expect("generic price tag"); + assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD")); let discount_tag = tags .iter() - .find(|t| t.get(0).map(|s| s.as_str()) == Some("price-discount-quantity")) + .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:discount")) .expect("discount tag"); assert!(discount_tag .get(1) - .map(|s| s.contains("\"ref_quantity\":\"bag\"")) + .map(|s| s.contains("\"scope\":\"bin\"")) .unwrap_or(false)); assert!(tags.iter().any(|t| { diff --git a/events/bindings/ts/package.json b/events/bindings/ts/package.json @@ -23,7 +23,7 @@ "build:cjs": "tsc -p tsconfig.cjs.json", "build": "npm run build:esm && npm run build:cjs", "prebuild": "npm run clean && npm run prepend-imports", - "prepend-imports": "bash -c 'f=./src/types.ts; line=\"import type { RadrootsCoreDecimal, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice } from \\\"@radroots/core-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")'", + "prepend-imports": "bash -c 'f=./src/types.ts; line=\"import type { RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from \\\"@radroots/core-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")'", "clean": "rimraf dist", "dev": "npm run watch", "watch": "tsc -w" @@ -40,4 +40,4 @@ "publishConfig": { "access": "public" } -} -\ No newline at end of file +} diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts @@ -1,3 +1,5 @@ +import type { RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from "@radroots/core-bindings"; + // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type JobFeedbackStatus = "payment_required" | "processing" | "error" | "success" | "partial"; diff --git a/trade/bindings/ts/package.json b/trade/bindings/ts/package.json @@ -23,7 +23,7 @@ "build:cjs": "tsc -p tsconfig.cjs.json", "build": "npm run build:esm && npm run build:cjs", "prebuild": "npm run clean && npm run prepend-imports", - "prepend-imports": "bash -c 'f=./src/types.ts; line=\"import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from \\\"@radroots/core-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")' && bash -c 'f=./src/types.ts; line=\"import type { RadrootsListingDiscount, RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")'", + "prepend-imports": "bash -c 'f=./src/types.ts; line=\"import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from \\\"@radroots/core-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")' && bash -c 'f=./src/types.ts; old=\"import type { RadrootsListingDiscount, RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; if grep -qxF \"$old\" \"$f\"; then grep -vxF \"$old\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\"; fi; line=\"import type { RadrootsListingImage, RadrootsNostrEventPtr } from \\\"@radroots/events-bindings\\\";\"; grep -qxF \"$line\" \"$f\" || (echo -e \"$line\\n\\n$(cat $f)\" > \"$f\")'", "clean": "rimraf dist", "dev": "npm run watch", "watch": "tsc -w" @@ -41,4 +41,4 @@ "publishConfig": { "access": "public" } -} -\ No newline at end of file +} diff --git a/trade/bindings/ts/src/types.ts b/trade/bindings/ts/src/types.ts @@ -1,13 +1,15 @@ -import type { RadrootsListingDiscount, RadrootsListingImage, RadrootsNostrEventPtr } from "@radroots/events-bindings"; +import type { RadrootsListingImage, RadrootsNostrEventPtr } from "@radroots/events-bindings"; -import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from "@radroots/core-bindings"; +import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit } from "@radroots/core-bindings"; // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RadrootsListing = { d_tag: string, farm: RadrootsListingFarmRef, product: RadrootsListingProduct, quantities: Array<RadrootsListingQuantity>, prices: RadrootsCoreQuantityPrice[], discounts?: RadrootsListingDiscount[] | null, inventory_available?: RadrootsCoreDecimal | null, availability?: RadrootsListingAvailability | null, delivery_method?: RadrootsListingDeliveryMethod | null, location?: RadrootsListingLocation | null, images?: RadrootsListingImage[] | null, }; +export type RadrootsListing = { d_tag: string, farm: RadrootsListingFarmRef, product: RadrootsListingProduct, primary_bin_id: string, bins: Array<RadrootsListingBin>, discounts?: RadrootsCoreDiscount[] | null, inventory_available?: RadrootsCoreDecimal | null, availability?: RadrootsListingAvailability | null, delivery_method?: RadrootsListingDeliveryMethod | null, location?: RadrootsListingLocation | null, images?: RadrootsListingImage[] | null, }; export type RadrootsListingAvailability = { "kind": "window", "amount": { start?: number | null, end?: number | null, } } | { "kind": "status", "amount": { status: RadrootsListingStatus, } }; +export type RadrootsListingBin = { bin_id: string, quantity: RadrootsCoreQuantity, price_per_canonical_unit: RadrootsCoreQuantityPrice, display_amount?: RadrootsCoreDecimal | null, display_unit?: RadrootsCoreUnit | null, display_label?: string | null, display_price?: RadrootsCoreMoney | null, display_price_unit?: RadrootsCoreUnit | null, }; + export type RadrootsListingDeliveryMethod = { "kind": "pickup" } | { "kind": "local_delivery" } | { "kind": "shipping" } | { "kind": "other", "amount": { method: string, } }; export type RadrootsListingFarmRef = { pubkey: string, d_tag: string, }; @@ -16,11 +18,9 @@ export type RadrootsListingLocation = { primary: string, city?: string | null, r export type RadrootsListingProduct = { key: string, title: string, category: string, summary?: string | null, process?: string | null, lot?: string | null, location?: string | null, profile?: string | null, year?: string | null, }; -export type RadrootsListingQuantity = { value: RadrootsCoreQuantity, label?: string | null, count?: number | null, }; - export type RadrootsListingStatus = { "kind": "active" } | { "kind": "sold" } | { "kind": "other", "amount": { value: string, } }; -export type RadrootsTradeListing = { listing_id: string, listing_addr: string, seller_pubkey: string, title: string, description: string, product_type: string, unit: RadrootsCoreUnit, unit_price: RadrootsCoreMoney, inventory_available: RadrootsCoreDecimal, availability: RadrootsListingAvailability, location: RadrootsListingLocation, delivery_method: RadrootsListingDeliveryMethod, listing: RadrootsListing, }; +export type RadrootsTradeListing = { listing_id: string, listing_addr: string, seller_pubkey: string, title: string, description: string, product_type: string, primary_bin_id: string, bin_quantity: RadrootsCoreQuantity, unit: RadrootsCoreUnit, unit_price: RadrootsCoreMoney, inventory_available: RadrootsCoreDecimal, availability: RadrootsListingAvailability, location: RadrootsListingLocation, delivery_method: RadrootsListingDeliveryMethod, listing: RadrootsListing, }; export type RadrootsTradeListingSubtotal = { price_amount: RadrootsCoreMoney, price_currency: RadrootsCoreCurrency, quantity_amount: RadrootsCoreDecimal, quantity_unit: RadrootsCoreUnit, }; @@ -70,9 +70,9 @@ export type TradeListingMessageType = "listing_validate_request" | "listing_vali export type TradeListingOrderRequest = { event: RadrootsNostrEventPtr, payload: TradeListingOrderRequestPayload, }; -export type TradeListingOrderRequestPayload = { price: RadrootsCoreQuantityPrice, quantity: RadrootsListingQuantity, }; +export type TradeListingOrderRequestPayload = { bin_id: string, bin_count: number, }; -export type TradeListingOrderResult = { quantity: RadrootsListingQuantity, price: RadrootsCoreQuantityPrice, discounts: RadrootsListingDiscount[], subtotal: RadrootsTradeListingSubtotal, total: RadrootsTradeListingTotal, }; +export type TradeListingOrderResult = { bin_id: string, bin_count: number, price: RadrootsCoreQuantityPrice, discounts: RadrootsCoreDiscount[], subtotal: RadrootsTradeListingSubtotal, total: RadrootsTradeListingTotal, }; export type TradeListingParseError = { "MissingTag": string } | { "InvalidTag": string } | { "InvalidNumber": string } | "InvalidUnit" | "InvalidCurrency" | { "InvalidJson": string } | { "InvalidDiscount": string }; @@ -92,13 +92,13 @@ export type TradeListingValidateRequest = { listing_event?: RadrootsNostrEventPt export type TradeListingValidateResult = { valid: boolean, errors: TradeListingValidationError[], }; -export type TradeListingValidationError = { "kind": "invalid_kind", "amount": { kind: number, } } | { "kind": "missing_listing_id" } | { "kind": "listing_event_not_found", "amount": { listing_addr: string, } } | { "kind": "listing_event_fetch_failed", "amount": { listing_addr: string, } } | { "kind": "parse_error", "amount": { error: TradeListingParseError, } } | { "kind": "invalid_seller" } | { "kind": "missing_farm_profile" } | { "kind": "missing_farm_record" } | { "kind": "missing_title" } | { "kind": "missing_description" } | { "kind": "missing_product_type" } | { "kind": "missing_price" } | { "kind": "invalid_price" } | { "kind": "missing_inventory" } | { "kind": "invalid_inventory" } | { "kind": "missing_availability" } | { "kind": "missing_location" } | { "kind": "missing_delivery_method" }; +export type TradeListingValidationError = { "kind": "invalid_kind", "amount": { kind: number, } } | { "kind": "missing_listing_id" } | { "kind": "listing_event_not_found", "amount": { listing_addr: string, } } | { "kind": "listing_event_fetch_failed", "amount": { listing_addr: string, } } | { "kind": "parse_error", "amount": { error: TradeListingParseError, } } | { "kind": "invalid_seller" } | { "kind": "missing_farm_profile" } | { "kind": "missing_farm_record" } | { "kind": "missing_title" } | { "kind": "missing_description" } | { "kind": "missing_product_type" } | { "kind": "missing_bins" } | { "kind": "missing_primary_bin" } | { "kind": "invalid_bin" } | { "kind": "missing_price" } | { "kind": "invalid_price" } | { "kind": "missing_inventory" } | { "kind": "invalid_inventory" } | { "kind": "missing_availability" } | { "kind": "missing_location" } | { "kind": "missing_delivery_method" }; export type TradeOrder = { order_id: string, listing_addr: string, buyer_pubkey: string, seller_pubkey: string, items: Array<TradeOrderItem>, discounts?: RadrootsCoreDiscountValue[] | null, notes?: string | null, status: TradeOrderStatus, }; -export type TradeOrderChange = { "kind": "quantity", "amount": { item_index: number, quantity: RadrootsCoreQuantity, } } | { "kind": "price", "amount": { item_index: number, unit_price: RadrootsCoreMoney, } } | { "kind": "item_add", "amount": { item: TradeOrderItem, } } | { "kind": "item_remove", "amount": { item_index: number, } }; +export type TradeOrderChange = { "kind": "bin_count", "amount": { item_index: number, bin_count: number, } } | { "kind": "item_add", "amount": { item: TradeOrderItem, } } | { "kind": "item_remove", "amount": { item_index: number, } }; -export type TradeOrderItem = { quantity: RadrootsCoreQuantity, unit_price: RadrootsCoreMoney, }; +export type TradeOrderItem = { bin_id: string, bin_count: number, }; export type TradeOrderResponse = { accepted: boolean, reason?: string | null, }; diff --git a/trade/src/listing/codec.rs b/trade/src/listing/codec.rs @@ -3,12 +3,15 @@ #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; -use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit}; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, + RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; use radroots_events::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingDiscount, RadrootsListingFarmRef, RadrootsListingImage, + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingImage, RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct, - RadrootsListingQuantity, RadrootsListingStatus, + RadrootsListingStatus, }; use radroots_events::kinds::KIND_FARM; use radroots_events::tags::TAG_D; @@ -17,9 +20,11 @@ use radroots_events_codec::listing::tags::{listing_tags_with_options, ListingTag #[cfg(feature = "ts-rs")] use ts_rs::TS; -const TAG_QUANTITY: &str = "quantity"; const TAG_PRICE: &str = "price"; -const TAG_PRICE_DISCOUNT_PREFIX: &str = "price-discount-"; +const TAG_RADROOTS_BIN: &str = "radroots:bin"; +const TAG_RADROOTS_PRICE: &str = "radroots:price"; +const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; +const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; const TAG_LOCATION: &str = "location"; const TAG_IMAGE: &str = "image"; const TAG_GEOHASH: &str = "g"; @@ -163,10 +168,10 @@ fn listing_from_tags( year: None, }; - let mut quantities: Vec<RadrootsListingQuantity> = Vec::new(); - let mut prices_extended: Vec<RadrootsCoreQuantityPrice> = Vec::new(); - let mut prices_generic: Vec<RadrootsCoreQuantityPrice> = Vec::new(); - let mut discounts: Vec<RadrootsListingDiscount> = Vec::new(); + let mut primary_bin_id: Option<String> = None; + let mut bin_drafts: Vec<BinDraft> = Vec::new(); + let mut bin_order = 0usize; + let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); let mut location: Option<RadrootsListingLocation> = None; let mut inventory_available: Option<RadrootsCoreDecimal> = None; let mut availability_status: Option<RadrootsListingStatus> = None; @@ -226,45 +231,131 @@ fn listing_from_tags( } "profile" => set_optional(&mut product.profile, tag.get(1)), "year" => set_optional(&mut product.year, tag.get(1)), - TAG_QUANTITY => { - let amount = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_QUANTITY.to_string()))?; - let unit = tag.get(2).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_QUANTITY.to_string()))?; - let amount = parse_decimal(amount, TAG_QUANTITY)?; - let unit = parse_unit(unit)?; - let label = tag.get(3).and_then(|v| clean_value(v)); - let count = tag.get(4).and_then(|v| v.parse::<u32>().ok()); - quantities.push(RadrootsListingQuantity { - value: RadrootsCoreQuantity::new(amount, unit), - label, - count, - }); - } TAG_PRICE => { - let amount = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; - let currency = tag.get(2).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; - if tag.len() >= 5 { - let quantity_amount = tag.get(3).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; - let unit = tag.get(4).ok_or_else(|| TradeListingParseError::InvalidTag(TAG_PRICE.to_string()))?; - let amount = parse_decimal(amount, TAG_PRICE)?; - let currency = parse_currency(currency)?; - let quantity_amount = parse_decimal(quantity_amount, TAG_PRICE)?; - let unit = parse_unit(unit)?; - let label = tag.get(5).and_then(|v| clean_value(v)); - let quantity = RadrootsCoreQuantity::new(quantity_amount, unit).with_optional_label(label); - prices_extended.push(RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new(amount, currency), - quantity, - }); + let _ = tag; + } + TAG_RADROOTS_PRIMARY_BIN => { + let value = tag + .get(1) + .and_then(|v| clean_value(v)) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?; + if let Some(existing) = primary_bin_id.as_ref() { + if existing != &value { + return Err(TradeListingParseError::InvalidTag( + TAG_RADROOTS_PRIMARY_BIN.to_string(), + )); + } } else { - let amount = parse_decimal(amount, TAG_PRICE)?; - let currency = parse_currency(currency)?; - let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); - prices_generic.push(RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new(amount, currency), - quantity, - }); + primary_bin_id = Some(value); + } + } + TAG_RADROOTS_BIN => { + if tag.len() < 4 { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); + } + if tag.len() > 7 { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); + } + let bin_id = tag + .get(1) + .and_then(|v| clean_value(v)) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?; + let amount = tag + .get(2) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?; + let unit = tag + .get(3) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?; + let amount = parse_decimal(amount, TAG_RADROOTS_BIN)?; + let unit = parse_unit(unit)?; + if unit != unit.canonical_unit() { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); + } + let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); + if bin.quantity.is_some() { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); + } + bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); + + if tag.len() >= 5 { + let display_amount = tag.get(4).ok_or_else(|| { + TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) + })?; + let display_amount = parse_decimal(display_amount, TAG_RADROOTS_BIN)?; + let display_unit = tag.get(5).ok_or_else(|| { + TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) + })?; + let display_unit = parse_unit(display_unit)?; + bin.display_amount = Some(display_amount); + bin.display_unit = Some(display_unit); + if tag.len() == 7 { + bin.display_label = tag.get(6).and_then(|v| clean_value(v)); + } + } + } + TAG_RADROOTS_PRICE => { + if tag.len() < 6 { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string())); + } + if tag.len() > 8 { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string())); + } + let bin_id = tag + .get(1) + .and_then(|v| clean_value(v)) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?; + let amount = tag + .get(2) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?; + let currency = tag + .get(3) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?; + let per_amount = tag + .get(4) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?; + let per_unit = tag + .get(5) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?; + let amount = parse_decimal(amount, TAG_RADROOTS_PRICE)?; + let currency = parse_currency(currency)?; + let per_amount = parse_decimal(per_amount, TAG_RADROOTS_PRICE)?; + let per_unit = parse_unit(per_unit)?; + let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(amount, currency), + RadrootsCoreQuantity::new(per_amount, per_unit), + ); + if !price_per_canonical_unit.is_price_per_canonical_unit() { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string())); + } + let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); + if bin.price_per_canonical_unit.is_some() { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string())); + } + bin.price_per_canonical_unit = Some(price_per_canonical_unit); + + if tag.len() == 7 { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string())); + } + if tag.len() == 8 { + let display_price = tag.get(6).ok_or_else(|| { + TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) + })?; + let display_unit = tag.get(7).ok_or_else(|| { + TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) + })?; + let display_price = parse_decimal(display_price, TAG_RADROOTS_PRICE)?; + let display_unit = parse_unit(display_unit)?; + bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency)); + bin.display_price_unit = Some(display_unit); } } + TAG_RADROOTS_DISCOUNT => { + let payload = tag + .get(1) + .ok_or_else(|| TradeListingParseError::InvalidTag(TAG_RADROOTS_DISCOUNT.to_string()))?; + let discount = parse_discount(payload)?; + discounts.push(discount); + } TAG_GEOHASH => { if let Some(value) = tag.get(1).and_then(|v| clean_value(v)) { geohash = Some(value); @@ -320,12 +411,6 @@ fn listing_from_tags( size, }); } - _ if key.starts_with(TAG_PRICE_DISCOUNT_PREFIX) => { - let kind = key.trim_start_matches(TAG_PRICE_DISCOUNT_PREFIX); - let payload = tag.get(1).ok_or_else(|| TradeListingParseError::InvalidDiscount(kind.to_string()))?; - let discount = parse_discount(kind, payload)?; - discounts.push(discount); - } _ => {} } } @@ -338,12 +423,6 @@ fn listing_from_tags( }, }; - let prices = if prices_extended.is_empty() { - prices_generic - } else { - prices_extended - }; - let location = location.map(|mut loc| { if loc.geohash.is_none() { loc.geohash = geohash; @@ -355,12 +434,22 @@ fn listing_from_tags( return Err(TradeListingParseError::InvalidTag(TAG_P.to_string())); } + let primary_bin_id = primary_bin_id + .and_then(|v| clean_value(&v)) + .ok_or_else(|| TradeListingParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?; + let bins = build_bins(bin_drafts)?; + if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) { + return Err(TradeListingParseError::InvalidTag( + TAG_RADROOTS_PRIMARY_BIN.to_string(), + )); + } + Ok(RadrootsListing { d_tag, farm: farm_ref, product, - quantities, - prices, + primary_bin_id, + bins, discounts: if discounts.is_empty() { None } else { Some(discounts) }, inventory_available, availability, @@ -430,13 +519,31 @@ mod tests { } #[test] - fn listing_prefers_extended_price_tags() { + fn listing_parses_radroots_bins() { let tags = vec![ vec!["key".into(), "coffee".into()], vec!["title".into(), "Coffee".into()], vec!["category".into(), "coffee".into()], - vec!["price".into(), "20".into(), "usd".into()], - vec!["price".into(), "20".into(), "usd".into(), "1".into(), "lb".into()], + vec!["radroots:primary_bin".into(), "bin-1".into()], + vec![ + "radroots:bin".into(), + "bin-1".into(), + "1000".into(), + "g".into(), + "1".into(), + "kg".into(), + "bag".into(), + ], + vec![ + "radroots:price".into(), + "bin-1".into(), + "0.01".into(), + "USD".into(), + "1".into(), + "g".into(), + "10".into(), + "kg".into(), + ], ]; let listing = listing_from_tags( @@ -447,29 +554,20 @@ mod tests { ) .expect("listing"); - assert_eq!(listing.prices.len(), 1); - assert_eq!(listing.prices[0].quantity.unit, RadrootsCoreUnit::MassLb); - } - - #[test] - fn listing_accepts_generic_price_tags() { - let tags = vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["price".into(), "20".into(), "usd".into()], - ]; - - let listing = listing_from_tags( - &tags, - "listing-1".to_string(), - farm_ref(), - "seller".to_string(), - ) - .expect("listing"); - - assert_eq!(listing.prices.len(), 1); - assert_eq!(listing.prices[0].quantity.unit, RadrootsCoreUnit::Each); + assert_eq!(listing.primary_bin_id, "bin-1"); + assert_eq!(listing.bins.len(), 1); + assert_eq!(listing.bins[0].quantity.unit, RadrootsCoreUnit::MassG); + assert_eq!( + listing.bins[0].price_per_canonical_unit.quantity.unit, + RadrootsCoreUnit::MassG + ); + assert_eq!( + listing.bins[0] + .display_unit + .expect("display unit") + .code(), + "kg" + ); } } @@ -515,81 +613,81 @@ fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> { Some(RadrootsListingImageSize { w, h }) } -fn parse_discount( - kind: &str, - payload: &str, -) -> Result<RadrootsListingDiscount, TradeListingParseError> { +fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, TradeListingParseError> { #[cfg(feature = "serde_json")] { - match kind { - "quantity" => { - let data: QuantityDiscountPayload = - serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; - Ok(RadrootsListingDiscount::Quantity { - ref_quantity: data.ref_quantity, - threshold: data.threshold, - value: data.value, - }) - } - "mass" => { - let data: MassDiscountPayload = - serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; - Ok(RadrootsListingDiscount::Mass { - threshold: data.threshold, - value: data.value, - }) - } - "subtotal" => { - let data: SubtotalDiscountPayload = - serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; - Ok(RadrootsListingDiscount::Subtotal { - threshold: data.threshold, - value: data.value, - }) - } - "total" => { - let data: TotalDiscountPayload = - serde_json::from_str(payload).map_err(|_| TradeListingParseError::InvalidDiscount(kind.to_string()))?; - Ok(RadrootsListingDiscount::Total { - total_min: data.total_min, - value: data.value, - }) - } - _ => Err(TradeListingParseError::InvalidDiscount(kind.to_string())), - } + serde_json::from_str(payload) + .map_err(|_| TradeListingParseError::InvalidDiscount(TAG_RADROOTS_DISCOUNT.to_string())) } #[cfg(not(feature = "serde_json"))] { - let _ = (kind, payload); + let _ = payload; Err(TradeListingParseError::InvalidJson("discount".to_string())) } } -#[cfg(feature = "serde_json")] -#[derive(serde::Deserialize, serde::Serialize, Clone)] -struct QuantityDiscountPayload { - ref_quantity: String, - threshold: RadrootsCoreQuantity, - value: RadrootsCoreMoney, -} - -#[cfg(feature = "serde_json")] -#[derive(serde::Deserialize, serde::Serialize, Clone)] -struct MassDiscountPayload { - threshold: RadrootsCoreQuantity, - value: RadrootsCoreMoney, +#[derive(Clone, Debug)] +struct BinDraft { + bin_id: String, + order_index: usize, + quantity: Option<RadrootsCoreQuantity>, + display_amount: Option<RadrootsCoreDecimal>, + display_unit: Option<RadrootsCoreUnit>, + display_label: Option<String>, + price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>, + display_price: Option<RadrootsCoreMoney>, + display_price_unit: Option<RadrootsCoreUnit>, } -#[cfg(feature = "serde_json")] -#[derive(serde::Deserialize, serde::Serialize, Clone)] -struct SubtotalDiscountPayload { - threshold: RadrootsCoreMoney, - value: radroots_core::RadrootsCoreDiscountValue, +fn upsert_bin<'a>( + bins: &'a mut Vec<BinDraft>, + bin_id: &str, + order_index: &mut usize, +) -> &'a mut BinDraft { + if let Some(pos) = bins.iter().position(|bin| bin.bin_id == bin_id) { + return &mut bins[pos]; + } + let draft = BinDraft { + bin_id: bin_id.to_string(), + order_index: *order_index, + quantity: None, + display_amount: None, + display_unit: None, + display_label: None, + price_per_canonical_unit: None, + display_price: None, + display_price_unit: None, + }; + bins.push(draft); + *order_index += 1; + let idx = bins.len() - 1; + &mut bins[idx] } -#[cfg(feature = "serde_json")] -#[derive(serde::Deserialize, serde::Serialize, Clone)] -struct TotalDiscountPayload { - total_min: RadrootsCoreMoney, - value: radroots_core::RadrootsCorePercent, +fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, TradeListingParseError> { + drafts.sort_by_key(|draft| draft.order_index); + let mut bins = Vec::with_capacity(drafts.len()); + for draft in drafts { + let quantity = draft + .quantity + .ok_or_else(|| TradeListingParseError::MissingTag(TAG_RADROOTS_BIN.to_string()))?; + let price = draft.price_per_canonical_unit.ok_or_else(|| { + TradeListingParseError::MissingTag(TAG_RADROOTS_PRICE.to_string()) + })?; + if quantity.unit != price.quantity.unit { + return Err(TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string())); + } + let bin = RadrootsListingBin { + bin_id: draft.bin_id, + quantity, + price_per_canonical_unit: price, + display_amount: draft.display_amount, + display_unit: draft.display_unit, + display_label: draft.display_label, + display_price: draft.display_price, + display_price_unit: draft.display_price_unit, + }; + bins.push(bin); + } + Ok(bins) } diff --git a/trade/src/listing/order.rs b/trade/src/listing/order.rs @@ -3,7 +3,7 @@ #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; -use radroots_core::{RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreQuantity}; +use radroots_core::RadrootsCoreDiscountValue; #[cfg(feature = "ts-rs")] use ts_rs::TS; @@ -12,10 +12,8 @@ use ts_rs::TS; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct TradeOrderItem { - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantity"))] - pub quantity: RadrootsCoreQuantity, - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreMoney"))] - pub unit_price: RadrootsCoreMoney, + pub bin_id: String, + pub bin_count: u32, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -24,15 +22,9 @@ pub struct TradeOrderItem { #[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))] #[derive(Clone, Debug, PartialEq, Eq)] pub enum TradeOrderChange { - Quantity { + BinCount { item_index: u32, - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantity"))] - quantity: RadrootsCoreQuantity, - }, - Price { - item_index: u32, - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreMoney"))] - unit_price: RadrootsCoreMoney, + bin_count: u32, }, ItemAdd { item: TradeOrderItem, diff --git a/trade/src/listing/price_ext.rs b/trade/src/listing/price_ext.rs @@ -1,41 +1,38 @@ use crate::listing::model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTotal}; use radroots_core::{ - RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, - RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps, + RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreQuantityPriceError, + RadrootsCoreQuantityPriceOps, }; -use radroots_events::listing::RadrootsListingQuantity; +use radroots_events::listing::RadrootsListingBin; -pub trait AsCoreQuantityPrice { - fn as_core_qp(&self) -> RadrootsCoreQuantityPrice; +pub trait BinPricingExt { + fn subtotal_for_count(&self, bin_count: u32) -> RadrootsTradeListingSubtotal; + fn total_for_count(&self, bin_count: u32) -> RadrootsTradeListingTotal; } -pub trait ListingPricingExt { - fn subtotal_for(&self, qty: &RadrootsListingQuantity) -> RadrootsTradeListingSubtotal; - fn total_for(&self, qty: &RadrootsListingQuantity) -> RadrootsTradeListingTotal; -} - -pub trait ListingPricingTryExt { - fn try_subtotal_for( +pub trait BinPricingTryExt { + fn try_subtotal_for_count( &self, - qty: &RadrootsListingQuantity, + bin_count: u32, ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError>; - fn try_total_for( + fn try_total_for_count( &self, - qty: &RadrootsListingQuantity, + bin_count: u32, ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError>; } #[inline] -fn effective_quantity(qty: &RadrootsListingQuantity) -> RadrootsCoreQuantity { - let count = qty.count.unwrap_or(1); - let amount = qty.value.amount * RadrootsCoreDecimal::from(count); - RadrootsCoreQuantity::new(amount, qty.value.unit) +fn effective_quantity(bin: &RadrootsListingBin, bin_count: u32) -> RadrootsCoreQuantity { + let amount = bin.quantity.amount * RadrootsCoreDecimal::from(bin_count); + RadrootsCoreQuantity::new(amount, bin.quantity.unit) } -impl ListingPricingExt for RadrootsCoreQuantityPrice { - fn subtotal_for(&self, qty: &RadrootsListingQuantity) -> RadrootsTradeListingSubtotal { - let effective_qty = effective_quantity(qty); - let money = self.cost_for_rounded(&effective_qty); +impl BinPricingExt for RadrootsListingBin { + fn subtotal_for_count(&self, bin_count: u32) -> RadrootsTradeListingSubtotal { + let effective_qty = effective_quantity(self, bin_count); + let money = self + .price_per_canonical_unit + .cost_for_rounded(&effective_qty); let currency = money.currency; RadrootsTradeListingSubtotal { @@ -46,8 +43,8 @@ impl ListingPricingExt for RadrootsCoreQuantityPrice { } } - fn total_for(&self, qty: &RadrootsListingQuantity) -> RadrootsTradeListingTotal { - let sub = self.subtotal_for(qty); + fn total_for_count(&self, bin_count: u32) -> RadrootsTradeListingTotal { + let sub = self.subtotal_for_count(bin_count); RadrootsTradeListingTotal { price_amount: sub.price_amount, price_currency: sub.price_currency, @@ -57,13 +54,15 @@ impl ListingPricingExt for RadrootsCoreQuantityPrice { } } -impl ListingPricingTryExt for RadrootsCoreQuantityPrice { - fn try_subtotal_for( +impl BinPricingTryExt for RadrootsListingBin { + fn try_subtotal_for_count( &self, - qty: &RadrootsListingQuantity, + bin_count: u32, ) -> Result<RadrootsTradeListingSubtotal, RadrootsCoreQuantityPriceError> { - let effective_qty = effective_quantity(qty); - let money = self.try_cost_for_rounded(&effective_qty)?; + let effective_qty = effective_quantity(self, bin_count); + let money = self + .price_per_canonical_unit + .try_cost_for_rounded(&effective_qty)?; let currency = money.currency; Ok(RadrootsTradeListingSubtotal { @@ -74,11 +73,11 @@ impl ListingPricingTryExt for RadrootsCoreQuantityPrice { }) } - fn try_total_for( + fn try_total_for_count( &self, - qty: &RadrootsListingQuantity, + bin_count: u32, ) -> Result<RadrootsTradeListingTotal, RadrootsCoreQuantityPriceError> { - let sub = self.try_subtotal_for(qty)?; + let sub = self.try_subtotal_for_count(bin_count)?; Ok(RadrootsTradeListingTotal { price_amount: sub.price_amount, price_currency: sub.price_currency, @@ -90,30 +89,36 @@ impl ListingPricingTryExt for RadrootsCoreQuantityPrice { #[cfg(test)] mod tests { - use super::ListingPricingTryExt; + use super::BinPricingTryExt; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit, }; - use radroots_events::listing::RadrootsListingQuantity; + use radroots_events::listing::RadrootsListingBin; #[test] fn try_subtotal_for_rejects_unit_mismatch() { - let price = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each), - ); - - let qty = RadrootsListingQuantity { - value: RadrootsCoreQuantity::new( + let bin = RadrootsListingBin { + bin_id: "bin-1".into(), + quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG, ), - label: None, - count: None, + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::Each, + ), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, }; - let err = price.try_subtotal_for(&qty).unwrap_err(); + let err = bin.try_subtotal_for_count(1).unwrap_err(); assert_eq!( err, RadrootsCoreQuantityPriceError::UnitMismatch { diff --git a/trade/src/listing/stage/order.rs b/trade/src/listing/stage/order.rs @@ -1,7 +1,6 @@ #[cfg(not(feature = "std"))] use alloc::vec::Vec; -use radroots_core::RadrootsCoreQuantityPrice; -use radroots_events::listing::{RadrootsListingDiscount, RadrootsListingQuantity}; +use radroots_core::{RadrootsCoreDiscount, RadrootsCoreQuantityPrice}; #[cfg(feature = "ts-rs")] use ts_rs::TS; @@ -12,10 +11,8 @@ use crate::listing::model::{RadrootsTradeListingSubtotal, RadrootsTradeListingTo #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingOrderRequestPayload { - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantityPrice"))] - pub price: RadrootsCoreQuantityPrice, - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsListingQuantity"))] - pub quantity: RadrootsListingQuantity, + pub bin_id: String, + pub bin_count: u32, } #[cfg_attr(feature = "ts-rs", derive(TS))] @@ -33,12 +30,12 @@ pub struct TradeListingOrderRequest { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] pub struct TradeListingOrderResult { - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsListingQuantity"))] - pub quantity: RadrootsListingQuantity, + pub bin_id: String, + pub bin_count: u32, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantityPrice"))] pub price: RadrootsCoreQuantityPrice, - #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsListingDiscount[]"))] - pub discounts: Vec<RadrootsListingDiscount>, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreDiscount[]"))] + pub discounts: Vec<RadrootsCoreDiscount>, pub subtotal: RadrootsTradeListingSubtotal, pub total: RadrootsTradeListingTotal, } diff --git a/trade/src/listing/validation.rs b/trade/src/listing/validation.rs @@ -3,7 +3,7 @@ #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; -use radroots_core::{RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit}; +use radroots_core::{RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit}; use radroots_events::{ RadrootsNostrEvent, kinds::KIND_LISTING, @@ -29,6 +29,9 @@ pub struct RadrootsTradeListing { pub title: String, pub description: String, pub product_type: String, + pub primary_bin_id: String, + #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreQuantity"))] + pub bin_quantity: RadrootsCoreQuantity, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreUnit"))] pub unit: RadrootsCoreUnit, #[cfg_attr(feature = "ts-rs", ts(type = "RadrootsCoreMoney"))] @@ -61,6 +64,9 @@ pub enum TradeListingValidationError { MissingTitle, MissingDescription, MissingProductType, + MissingBins, + MissingPrimaryBin, + InvalidBin, MissingPrice, InvalidPrice, MissingInventory, @@ -102,6 +108,11 @@ impl core::fmt::Display for TradeListingValidationError { TradeListingValidationError::MissingProductType => { write!(f, "missing listing product type") } + TradeListingValidationError::MissingBins => write!(f, "missing listing bins"), + TradeListingValidationError::MissingPrimaryBin => { + write!(f, "missing primary listing bin") + } + TradeListingValidationError::InvalidBin => write!(f, "invalid listing bin"), TradeListingValidationError::MissingPrice => write!(f, "missing listing price"), TradeListingValidationError::InvalidPrice => write!(f, "invalid listing price"), TradeListingValidationError::MissingInventory => { @@ -173,18 +184,46 @@ pub fn validate_listing_event( return Err(TradeListingValidationError::MissingProductType); } - let price = listing - .prices - .first() - .ok_or(TradeListingValidationError::MissingPrice)?; - if price.amount.amount.is_sign_negative() { + if listing.bins.is_empty() { + return Err(TradeListingValidationError::MissingBins); + } + let primary_bin_id = listing.primary_bin_id.trim().to_string(); + if primary_bin_id.is_empty() { + return Err(TradeListingValidationError::MissingPrimaryBin); + } + let primary_bin = listing + .bins + .iter() + .find(|bin| bin.bin_id == primary_bin_id) + .ok_or(TradeListingValidationError::MissingPrimaryBin)?; + + if primary_bin.quantity.amount.is_sign_negative() { + return Err(TradeListingValidationError::InvalidBin); + } + if !primary_bin.quantity.is_canonical() { + return Err(TradeListingValidationError::InvalidBin); + } + if !primary_bin + .price_per_canonical_unit + .is_price_per_canonical_unit() + { + return Err(TradeListingValidationError::InvalidPrice); + } + if primary_bin + .price_per_canonical_unit + .amount + .amount + .is_sign_negative() + { + return Err(TradeListingValidationError::InvalidPrice); + } + if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit { return Err(TradeListingValidationError::InvalidPrice); } let inventory_available = listing .inventory_available .clone() - .or_else(|| derive_inventory(&listing)) .ok_or(TradeListingValidationError::MissingInventory)?; if inventory_available.is_sign_negative() { return Err(TradeListingValidationError::InvalidInventory); @@ -210,8 +249,10 @@ pub fn validate_listing_event( title, description, product_type, - unit: price.quantity.unit, - unit_price: price.amount.clone(), + primary_bin_id: primary_bin_id.clone(), + bin_quantity: primary_bin.quantity.clone(), + unit: primary_bin.quantity.unit, + unit_price: primary_bin.price_per_canonical_unit.amount.clone(), inventory_available, availability, location, @@ -220,13 +261,6 @@ pub fn validate_listing_event( }) } -fn derive_inventory(listing: &RadrootsListing) -> Option<RadrootsCoreDecimal> { - listing.quantities.iter().find_map(|qty| { - qty.count - .map(|count| qty.value.amount * RadrootsCoreDecimal::from(count)) - }) -} - #[cfg(test)] mod tests { use super::{TradeListingValidationError, validate_listing_event}; @@ -239,8 +273,8 @@ mod tests { kinds::KIND_LISTING, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingFarmRef, RadrootsListingLocation, RadrootsListingProduct, - RadrootsListingQuantity, + RadrootsListingBin, RadrootsListingFarmRef, RadrootsListingLocation, + RadrootsListingProduct, }, }; @@ -262,26 +296,31 @@ mod tests { profile: None, year: None, }, - quantities: vec![RadrootsListingQuantity { - value: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassLb, - ), - label: None, - count: Some(5), - }], - prices: vec![RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), + primary_bin_id: "bin-1".into(), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".into(), quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassLb, + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, }], discounts: None, - inventory_available: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), availability: Some(RadrootsListingAvailability::Status { status: radroots_events::listing::RadrootsListingStatus::Active, }), @@ -391,7 +430,7 @@ mod tests { #[test] fn validate_listing_rejects_missing_inventory() { let mut listing = base_listing(); - listing.quantities[0].count = None; + listing.inventory_available = None; let event = base_event(&listing); let err = validate_listing_event(&event).unwrap_err(); assert!(matches!(err, TradeListingValidationError::MissingInventory));