commit 886f6e99494c339e83ce138c43735efc11f876fd
parent f114708403f2bfbb404a6f90b192a7b87eba8be1
Author: triesap <tyson@radroots.org>
Date: Wed, 13 May 2026 03:25:22 +0000
cli: gate market checkout actions
- add typed market readiness fields for protocol, marketplace, and checkout state
- gate market buyer-intent actions on checkout-enabled listings only
- expose checkout-disabled reason codes in search and listing get output
- cover enabled and disabled checkout paths in nested CLI tests
Diffstat:
6 files changed, 398 insertions(+), 45 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -4,11 +4,13 @@ use std::process::ExitCode;
use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal};
use radroots_events::farm::RadrootsFarm;
+use radroots_events::kinds::KIND_LISTING;
use radroots_events::listing::RadrootsListingLocation;
use radroots_events::profile::RadrootsProfile;
use radroots_events::trade::{
RadrootsTradeOrderEconomics, RadrootsTradePaymentMethod, RadrootsTradeSettlementDecision,
};
+use radroots_events_codec::trade::RadrootsTradeListingAddress;
use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord;
use serde::Serialize;
@@ -1052,6 +1054,145 @@ pub struct FindHyfView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct MarketReadinessView {
+ pub protocol_valid: bool,
+ pub marketplace_eligible: bool,
+ pub checkout_enabled: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub reason_codes: Vec<String>,
+}
+
+impl MarketReadinessView {
+ pub fn unavailable(reason_code: impl Into<String>) -> Self {
+ Self {
+ protocol_valid: false,
+ marketplace_eligible: false,
+ checkout_enabled: false,
+ reason_codes: vec![reason_code.into()],
+ }
+ }
+
+ pub fn from_market_projection(
+ listing_addr: Option<&str>,
+ title: Option<&str>,
+ category: Option<&str>,
+ available_amount: Option<i64>,
+ price_amount: f64,
+ price_currency: &str,
+ price_per_amount: f64,
+ ) -> Self {
+ let protocol_valid = listing_addr.is_some_and(|listing_addr| {
+ RadrootsTradeListingAddress::parse(listing_addr)
+ .is_ok_and(|parsed| parsed.kind == KIND_LISTING)
+ });
+ let marketplace_eligible = protocol_valid
+ && title.is_some_and(|title| !title.trim().is_empty())
+ && category.is_some_and(|category| !category.trim().is_empty());
+ let inventory_available = available_amount.is_some_and(|amount| amount > 0);
+ let price_available = price_amount.is_finite()
+ && price_amount > 0.0
+ && !price_currency.trim().is_empty()
+ && price_per_amount.is_finite()
+ && price_per_amount > 0.0;
+ let checkout_enabled = marketplace_eligible && inventory_available && price_available;
+ let mut reason_codes = Vec::new();
+ if !protocol_valid {
+ reason_codes.push("listing_protocol_invalid".to_owned());
+ }
+ if protocol_valid && !marketplace_eligible {
+ reason_codes.push("listing_marketplace_ineligible".to_owned());
+ }
+ if marketplace_eligible && !checkout_enabled {
+ reason_codes.push("listing_checkout_disabled".to_owned());
+ if !inventory_available {
+ reason_codes.push("listing_inventory_unavailable".to_owned());
+ }
+ if !price_available {
+ reason_codes.push("listing_price_unavailable".to_owned());
+ }
+ }
+ Self {
+ protocol_valid,
+ marketplace_eligible,
+ checkout_enabled,
+ reason_codes,
+ }
+ }
+}
+
+#[cfg(test)]
+mod market_readiness_tests {
+ use super::MarketReadinessView;
+
+ const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg";
+
+ #[test]
+ fn market_readiness_separates_protocol_marketplace_and_checkout_state() {
+ let enabled = MarketReadinessView::from_market_projection(
+ Some(LISTING_ADDR),
+ Some("Eggs"),
+ Some("eggs"),
+ Some(1),
+ 6.0,
+ "USD",
+ 1.0,
+ );
+ assert!(enabled.protocol_valid);
+ assert!(enabled.marketplace_eligible);
+ assert!(enabled.checkout_enabled);
+ assert!(enabled.reason_codes.is_empty());
+
+ let invalid = MarketReadinessView::from_market_projection(
+ None,
+ Some("Eggs"),
+ Some("eggs"),
+ Some(1),
+ 6.0,
+ "USD",
+ 1.0,
+ );
+ assert!(!invalid.protocol_valid);
+ assert!(!invalid.marketplace_eligible);
+ assert!(!invalid.checkout_enabled);
+ assert_eq!(invalid.reason_codes, vec!["listing_protocol_invalid"]);
+
+ let ineligible = MarketReadinessView::from_market_projection(
+ Some(LISTING_ADDR),
+ Some(" "),
+ Some("eggs"),
+ Some(1),
+ 6.0,
+ "USD",
+ 1.0,
+ );
+ assert!(ineligible.protocol_valid);
+ assert!(!ineligible.marketplace_eligible);
+ assert!(!ineligible.checkout_enabled);
+ assert_eq!(
+ ineligible.reason_codes,
+ vec!["listing_marketplace_ineligible"]
+ );
+
+ let checkout_disabled = MarketReadinessView::from_market_projection(
+ Some(LISTING_ADDR),
+ Some("Eggs"),
+ Some("eggs"),
+ Some(0),
+ 6.0,
+ "USD",
+ 1.0,
+ );
+ assert!(checkout_disabled.protocol_valid);
+ assert!(checkout_disabled.marketplace_eligible);
+ assert!(!checkout_disabled.checkout_enabled);
+ assert_eq!(
+ checkout_disabled.reason_codes,
+ vec!["listing_checkout_disabled", "listing_inventory_unavailable"]
+ );
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct JobListView {
pub state: String,
pub source: String,
@@ -2614,6 +2755,8 @@ pub struct ListingGetView {
pub state: String,
pub source: String,
pub lookup: String,
+ #[serde(flatten)]
+ pub readiness: MarketReadinessView,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -2810,6 +2953,8 @@ pub struct ListingMutationEventView {
pub struct FindResultView {
pub id: String,
pub product_key: String,
+ #[serde(flatten)]
+ pub readiness: MarketReadinessView,
#[serde(skip_serializing_if = "Option::is_none")]
pub listing_addr: Option<String>,
pub title: String,
diff --git a/src/operation_market.rs b/src/operation_market.rs
@@ -1,5 +1,3 @@
-use radroots_events::kinds::KIND_LISTING;
-use radroots_events_codec::trade::RadrootsTradeListingAddress;
use serde::Serialize;
use serde_json::Value;
@@ -106,7 +104,7 @@ fn market_product_search_view(mut view: FindView) -> FindView {
"radroots market listing get {}",
result.product_key
)];
- if listing_addr_can_back_basket(result.listing_addr.as_deref()) {
+ if result.readiness.checkout_enabled {
actions.push("radroots basket create".to_owned());
}
actions
@@ -128,7 +126,7 @@ fn market_product_search_view(mut view: FindView) -> FindView {
fn market_listing_get_view(mut view: ListingGetView) -> ListingGetView {
view.actions = match view.state.as_str() {
"ready" => {
- if listing_addr_can_back_basket(view.listing_addr.as_deref()) {
+ if view.readiness.checkout_enabled {
vec!["radroots basket create".to_owned()]
} else {
Vec::new()
@@ -147,13 +145,6 @@ fn market_listing_get_view(mut view: ListingGetView) -> ListingGetView {
view
}
-fn listing_addr_can_back_basket(listing_addr: Option<&str>) -> bool {
- let Some(listing_addr) = listing_addr else {
- return false;
- };
- RadrootsTradeListingAddress::parse(listing_addr).is_ok_and(|parsed| parsed.kind == KIND_LISTING)
-}
-
fn required_query_terms<P>(
request: &OperationRequest<P>,
) -> Result<Vec<String>, OperationAdapterError>
@@ -260,7 +251,7 @@ mod tests {
use super::{MarketOperationService, market_listing_get_view, market_product_search_view};
use crate::domain::runtime::{
FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView,
- ListingGetView, SyncFreshnessView,
+ ListingGetView, MarketReadinessView, SyncFreshnessView,
};
use crate::operation_adapter::{
MarketListingGetRequest, MarketProductSearchRequest, MarketRefreshRequest,
@@ -447,6 +438,7 @@ mod tests {
results: vec![FindResultView {
id: "listing_eggs".to_owned(),
product_key: "eggs".to_owned(),
+ readiness: readiness_enabled(),
listing_addr: Some(LISTING_ADDR.to_owned()),
title: "Eggs".to_owned(),
category: "eggs".to_owned(),
@@ -474,6 +466,7 @@ mod tests {
state: "ready".to_owned(),
source: "test".to_owned(),
lookup: "eggs".to_owned(),
+ readiness: readiness_enabled(),
listing_id: Some("listing_eggs".to_owned()),
product_key: Some("eggs".to_owned()),
listing_addr: Some(LISTING_ADDR.to_owned()),
@@ -498,6 +491,62 @@ mod tests {
);
}
+ #[test]
+ fn market_ready_actions_require_checkout_enabled() {
+ let disabled_search = market_product_search_view(FindView {
+ state: "ready".to_owned(),
+ source: "test".to_owned(),
+ query: "eggs".to_owned(),
+ count: 1,
+ relay_count: 1,
+ replica_db: "ready".to_owned(),
+ freshness: freshness(),
+ results: vec![FindResultView {
+ id: "listing_eggs".to_owned(),
+ product_key: "eggs".to_owned(),
+ readiness: readiness_disabled(),
+ listing_addr: Some(LISTING_ADDR.to_owned()),
+ title: "Eggs".to_owned(),
+ category: "eggs".to_owned(),
+ summary: None,
+ location_primary: None,
+ available: quantity(),
+ price: price(),
+ provenance: provenance(),
+ hyf: None,
+ }],
+ hyf: None,
+ reason: None,
+ actions: Vec::new(),
+ });
+
+ assert_eq!(
+ disabled_search.actions,
+ vec!["radroots market listing get eggs".to_owned()]
+ );
+
+ let disabled_listing = market_listing_get_view(ListingGetView {
+ state: "ready".to_owned(),
+ source: "test".to_owned(),
+ lookup: "eggs".to_owned(),
+ readiness: readiness_disabled(),
+ listing_id: Some("listing_eggs".to_owned()),
+ product_key: Some("eggs".to_owned()),
+ listing_addr: Some(LISTING_ADDR.to_owned()),
+ title: Some("Eggs".to_owned()),
+ category: Some("eggs".to_owned()),
+ description: None,
+ location_primary: None,
+ available: Some(quantity()),
+ price: Some(price()),
+ provenance: provenance(),
+ reason: None,
+ actions: Vec::new(),
+ });
+
+ assert!(disabled_listing.actions.is_empty());
+ }
+
fn freshness() -> SyncFreshnessView {
SyncFreshnessView {
state: "fresh".to_owned(),
@@ -534,6 +583,27 @@ mod tests {
}
}
+ fn readiness_enabled() -> MarketReadinessView {
+ MarketReadinessView {
+ protocol_valid: true,
+ marketplace_eligible: true,
+ checkout_enabled: true,
+ reason_codes: Vec::new(),
+ }
+ }
+
+ fn readiness_disabled() -> MarketReadinessView {
+ MarketReadinessView {
+ protocol_valid: true,
+ marketplace_eligible: true,
+ checkout_enabled: false,
+ reason_codes: vec![
+ "listing_checkout_disabled".to_owned(),
+ "listing_inventory_unavailable".to_owned(),
+ ],
+ }
+ }
+
fn sample_config(root: &Path) -> RuntimeConfig {
let data = root.join("data");
let logs = root.join("logs");
diff --git a/src/runtime/find.rs b/src/runtime/find.rs
@@ -3,7 +3,7 @@ use radroots_sql_core::SqliteExecutor;
use crate::domain::runtime::{
FindHyfView, FindPriceView, FindQuantityView, FindResultHyfView, FindResultProvenanceView,
- FindResultView, FindView,
+ FindResultView, FindView, MarketReadinessView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
@@ -79,30 +79,47 @@ pub fn search(config: &RuntimeConfig, args: &FindQueryArgs) -> Result<FindView,
};
let results = rows
.into_iter()
- .map(|row| FindResultView {
- id: row.id,
- product_key: row.key,
- listing_addr: row.listing_addr.and_then(non_empty),
- title: row.title,
- category: row.category,
- summary: non_empty(row.summary),
- location_primary: row.location_primary.and_then(non_empty),
- available: FindQuantityView {
- total_amount: row.qty_amt,
- total_unit: row.qty_unit,
- label: row.qty_label.and_then(non_empty),
- available_amount: row.qty_avail,
- },
- price: FindPriceView {
- amount: row.price_amt,
- currency: row.price_currency,
- per_amount: row.price_qty_amt,
- per_unit: row.price_qty_unit,
- },
- provenance: result_provenance.clone(),
- hyf: applied_query_rewrite
- .as_ref()
- .map(AppliedQueryRewrite::to_result_view),
+ .map(|row| {
+ let listing_addr = row.listing_addr.and_then(non_empty);
+ let available_amount = row.qty_avail;
+ let price_amount = row.price_amt;
+ let price_currency = row.price_currency;
+ let price_per_amount = row.price_qty_amt;
+ let readiness = MarketReadinessView::from_market_projection(
+ listing_addr.as_deref(),
+ Some(row.title.as_str()),
+ Some(row.category.as_str()),
+ available_amount,
+ price_amount,
+ price_currency.as_str(),
+ price_per_amount,
+ );
+ FindResultView {
+ id: row.id,
+ product_key: row.key,
+ readiness,
+ listing_addr,
+ title: row.title,
+ category: row.category,
+ summary: non_empty(row.summary),
+ location_primary: row.location_primary.and_then(non_empty),
+ available: FindQuantityView {
+ total_amount: row.qty_amt,
+ total_unit: row.qty_unit,
+ label: row.qty_label.and_then(non_empty),
+ available_amount,
+ },
+ price: FindPriceView {
+ amount: price_amount,
+ currency: price_currency,
+ per_amount: price_per_amount,
+ per_unit: row.price_qty_unit,
+ },
+ provenance: result_provenance.clone(),
+ hyf: applied_query_rewrite
+ .as_ref()
+ .map(AppliedQueryRewrite::to_result_view),
+ }
})
.collect::<Vec<_>>();
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -33,7 +33,7 @@ use crate::domain::runtime::{
FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView,
ListingMutationEventView, ListingMutationLocalReplicaView, ListingMutationView, ListingNewView,
ListingRebindView, ListingSummaryView, ListingValidateView, ListingValidationIssueView,
- RelayFailureView,
+ MarketReadinessView, RelayFailureView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
@@ -943,6 +943,7 @@ pub fn get(
state: "unconfigured".to_owned(),
source: LISTING_READ_SOURCE.to_owned(),
lookup: args.key.clone(),
+ readiness: MarketReadinessView::unavailable("local_replica_not_initialized"),
listing_id: None,
product_key: None,
listing_addr: None,
@@ -965,6 +966,7 @@ pub fn get(
state: "missing".to_owned(),
source: LISTING_READ_SOURCE.to_owned(),
lookup: args.key.clone(),
+ readiness: MarketReadinessView::unavailable("market_listing_missing"),
listing_id: None,
product_key: None,
listing_addr: None,
@@ -986,13 +988,29 @@ pub fn get(
});
};
+ let listing_addr = row.listing_addr.and_then(non_empty);
+ let available_amount = row.qty_avail;
+ let price_amount = row.price_amt;
+ let price_currency = row.price_currency;
+ let price_per_amount = row.price_qty_amt;
+ let readiness = MarketReadinessView::from_market_projection(
+ listing_addr.as_deref(),
+ Some(row.title.as_str()),
+ Some(row.category.as_str()),
+ available_amount,
+ price_amount,
+ price_currency.as_str(),
+ price_per_amount,
+ );
+
Ok(ListingGetView {
state: "ready".to_owned(),
source: LISTING_READ_SOURCE.to_owned(),
lookup: args.key.clone(),
+ readiness,
listing_id: Some(row.id),
product_key: Some(row.key),
- listing_addr: row.listing_addr.and_then(non_empty),
+ listing_addr,
title: Some(row.title),
category: Some(row.category),
description: non_empty(row.summary),
@@ -1001,12 +1019,12 @@ pub fn get(
total_amount: row.qty_amt,
total_unit: row.qty_unit,
label: row.qty_label.and_then(non_empty),
- available_amount: row.qty_avail,
+ available_amount,
}),
price: Some(FindPriceView {
- amount: row.price_amt,
- currency: row.price_currency,
- per_amount: row.price_qty_amt,
+ amount: price_amount,
+ currency: price_currency,
+ per_amount: price_per_amount,
per_unit: row.price_qty_unit,
}),
provenance,
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
@@ -287,6 +287,22 @@ pub fn remove_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str
.expect("delete listing row");
}
+pub fn update_orderable_listing_available_amount(
+ sandbox: &RadrootsCliSandbox,
+ listing_addr: &str,
+ available_amount: i64,
+) {
+ let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db");
+ let params = serde_json::to_string(&serde_json::json!([available_amount, listing_addr]))
+ .expect("update listing params");
+ executor
+ .exec(
+ "UPDATE trade_product SET qty_avail = ? WHERE listing_addr = ?;",
+ params.as_str(),
+ )
+ .expect("update listing available amount");
+}
+
pub fn replace_latest_listing_event_id(
sandbox: &RadrootsCliSandbox,
listing_addr: &str,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -28,8 +28,8 @@ use support::{
assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret,
json_from_stdout, make_listing_publishable, make_listing_publishable_with_seller,
ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id,
- seed_orderable_listing, toml_string, write_public_identity_profile,
- write_secret_identity_profile,
+ seed_orderable_listing, toml_string, update_orderable_listing_available_amount,
+ write_public_identity_profile, write_secret_identity_profile,
};
const LISTING_ADDR: &str =
@@ -3443,6 +3443,93 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() {
}
#[test]
+fn market_checkout_readiness_gates_buyer_intent_actions() {
+ let sandbox = RadrootsCliSandbox::new();
+ seed_orderable_listing(&sandbox, LISTING_ADDR);
+
+ let search = sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]);
+ assert_eq!(search["operation_id"], "market.product.search");
+ let result = &search["result"]["results"][0];
+ assert_eq!(result["protocol_valid"], true);
+ assert_eq!(result["marketplace_eligible"], true);
+ assert_eq!(result["checkout_enabled"], true);
+ assert!(result.get("reason_codes").is_none());
+ assert!(
+ search["result"]["actions"]
+ .as_array()
+ .expect("search actions")
+ .iter()
+ .any(|action| action == "radroots basket create")
+ );
+
+ let listing = sandbox.json_success(&[
+ "--format",
+ "json",
+ "market",
+ "listing",
+ "get",
+ "pasture-eggs",
+ ]);
+ assert_eq!(listing["operation_id"], "market.listing.get");
+ assert_eq!(listing["result"]["protocol_valid"], true);
+ assert_eq!(listing["result"]["marketplace_eligible"], true);
+ assert_eq!(listing["result"]["checkout_enabled"], true);
+ assert!(
+ listing["result"]["actions"]
+ .as_array()
+ .expect("listing actions")
+ .iter()
+ .any(|action| action == "radroots basket create")
+ );
+
+ update_orderable_listing_available_amount(&sandbox, LISTING_ADDR, 0);
+
+ let disabled_search =
+ sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]);
+ let disabled_result = &disabled_search["result"]["results"][0];
+ assert_eq!(disabled_result["protocol_valid"], true);
+ assert_eq!(disabled_result["marketplace_eligible"], true);
+ assert_eq!(disabled_result["checkout_enabled"], false);
+ assert_eq!(
+ disabled_result["reason_codes"][0],
+ "listing_checkout_disabled"
+ );
+ assert_eq!(
+ disabled_result["reason_codes"][1],
+ "listing_inventory_unavailable"
+ );
+ assert!(
+ disabled_search["result"]["actions"]
+ .as_array()
+ .expect("disabled search actions")
+ .iter()
+ .all(|action| action != "radroots basket create")
+ );
+
+ let disabled_listing = sandbox.json_success(&[
+ "--format",
+ "json",
+ "market",
+ "listing",
+ "get",
+ "pasture-eggs",
+ ]);
+ assert_eq!(disabled_listing["result"]["protocol_valid"], true);
+ assert_eq!(disabled_listing["result"]["marketplace_eligible"], true);
+ assert_eq!(disabled_listing["result"]["checkout_enabled"], false);
+ assert_eq!(
+ disabled_listing["result"]["reason_codes"][0],
+ "listing_checkout_disabled"
+ );
+ assert!(
+ disabled_listing["result"]
+ .get("actions")
+ .and_then(Value::as_array)
+ .is_none_or(Vec::is_empty)
+ );
+}
+
+#[test]
fn required_approval_token_rejects_absent_empty_and_whitespace_values() {
let sandbox = RadrootsCliSandbox::new();
let public_identity = identity_public(61);