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:
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));