app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 09db261376e23fe3b4ca0a3594f772ca2a982776
parent 62ce4184e6152feb66845441f911e3388225edf9
Author: triesap <tyson@radroots.org>
Date:   Tue,  2 Jun 2026 23:07:27 -0700

store: project active trade events through reducer

- add shared radroots_trade dependency for app sqlite importer
- decode active order lifecycle event kinds into reducer records
- apply reducer status output to existing order queue states
- cover revision, cancellation, fulfillment, receipt, and conflict imports

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcrates/store/Cargo.toml | 1+
Mcrates/store/src/interop.rs | 1096++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 1035 insertions(+), 64 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5136,6 +5136,7 @@ dependencies = [ "radroots_events_codec", "radroots_local_events", "radroots_sql_core", + "radroots_trade", "rusqlite", "serde_json", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml @@ -43,6 +43,7 @@ radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } radroots_secret_vault = { path = "../lib/crates/secret_vault", default-features = false, features = ["std"] } radroots_sdk = { path = "../lib/crates/sdk", features = ["relay-client", "signing"] } radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } +radroots_trade = { path = "../lib/crates/trade", default-features = false, features = ["std", "serde", "serde_json"] } radroots_app_core = { path = "crates/runtime", version = "0.1.0" } radroots_app_i18n = { path = "crates/i18n", version = "0.1.0" } radroots_app_types = { path = "crates/types", version = "0.1.0" } diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml @@ -15,6 +15,7 @@ radroots_local_events.workspace = true radroots_app_view.workspace = true radroots_app_sync.workspace = true radroots_sql_core.workspace = true +radroots_trade.workspace = true rusqlite.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -2,23 +2,36 @@ use std::{fs, path::Path}; use radroots_app_view::{ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FulfillmentWindowId, OrderId, PickupLocationId, ProductId, ProductStatus, + FulfillmentWindowId, OrderId, OrderStatus, PickupLocationId, ProductId, ProductStatus, }; use radroots_events::{ RadrootsNostrEvent, - kinds::{KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_RESPONSE}, - trade::{ - RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, + kinds::{ + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_REQUEST, + KIND_TRADE_ORDER_RESPONSE, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, + KIND_TRADE_RECEIPT, }, + trade::{RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderRequested}, }; use radroots_events_codec::trade::{ + active_trade_buyer_receipt_from_event, active_trade_event_context_from_tags, + active_trade_fulfillment_update_from_event, active_trade_order_cancel_from_event, active_trade_order_decision_from_event, active_trade_order_request_from_event, + active_trade_order_revision_decision_from_event, + active_trade_order_revision_proposal_from_event, }; use radroots_local_events::{ LocalEventRecord, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, RelayDeliveryState, SourceRuntime, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; +use radroots_trade::order::{ + RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, + RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderProjection, + RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderRequestRecord, + RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord, + RadrootsActiveOrderStatus, reduce_active_order_events, +}; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; use uuid::Uuid; @@ -33,6 +46,20 @@ const KIND_LISTING: i64 = 30402; const KIND_LISTING_DRAFT: i64 = 30403; const KIND_ORDER_REQUEST: i64 = KIND_TRADE_ORDER_REQUEST as i64; const KIND_ORDER_DECISION: i64 = KIND_TRADE_ORDER_RESPONSE as i64; +const KIND_ORDER_REVISION: i64 = KIND_TRADE_ORDER_REVISION as i64; +const KIND_ORDER_REVISION_DECISION: i64 = KIND_TRADE_ORDER_REVISION_RESPONSE as i64; +const KIND_ORDER_CANCEL: i64 = KIND_TRADE_CANCEL as i64; +const KIND_ORDER_FULFILLMENT: i64 = KIND_TRADE_FULFILLMENT_UPDATE as i64; +const KIND_ORDER_RECEIPT: i64 = KIND_TRADE_RECEIPT as i64; +const ACTIVE_ORDER_EVENT_KINDS: [i64; 7] = [ + KIND_ORDER_REQUEST, + KIND_ORDER_DECISION, + KIND_ORDER_REVISION, + KIND_ORDER_REVISION_DECISION, + KIND_ORDER_CANCEL, + KIND_ORDER_FULFILLMENT, + KIND_ORDER_RECEIPT, +]; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct AppLocalInteropImportReport { @@ -658,8 +685,7 @@ impl<'a> AppLocalInteropRepository<'a> { match record.event_kind { Some(KIND_FARM) => self.import_signed_farm(record), Some(KIND_LISTING | KIND_LISTING_DRAFT) => self.import_signed_listing(record), - Some(KIND_ORDER_REQUEST) => self.import_signed_order_request(record), - Some(KIND_ORDER_DECISION) => self.import_signed_order_decision(record), + Some(kind) if active_order_event_kind(kind) => self.import_signed_active_order(record), _ => Ok(Some(ProjectionRecord { kind: "signed_event", projected_id: record.event_id.clone(), @@ -1010,32 +1036,57 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(Some(projection_record)) } - fn import_signed_order_request( + fn import_signed_active_order( &self, record: &LocalEventRecord, ) -> Result<Option<ProjectionRecord>, AppSqliteError> { + if !signed_event_record_is_usable(record) { + return Ok(Some(signed_event_projection(record))); + } let Some(event) = signed_event_from_record(record)? else { return Ok(Some(signed_event_projection(record))); }; - let Ok(envelope) = active_trade_order_request_from_event(&event) else { + let Some(current_evidence) = active_order_evidence_from_event(&event) else { return Ok(Some(signed_event_projection(record))); }; - self.upsert_order_request(record, &envelope.payload)?; + self.project_active_order(record, current_evidence)?; Ok(Some(signed_event_projection(record))) } - fn import_signed_order_decision( + fn project_active_order( &self, record: &LocalEventRecord, - ) -> Result<Option<ProjectionRecord>, AppSqliteError> { - let Some(event) = signed_event_from_record(record)? else { - return Ok(Some(signed_event_projection(record))); - }; - let Ok(envelope) = active_trade_order_decision_from_event(&event) else { - return Ok(Some(signed_event_projection(record))); + current_evidence: ActiveOrderEvidence, + ) -> Result<(), AppSqliteError> { + if let Some(payload) = current_evidence.request_payload() { + self.upsert_order_request(record, payload)?; + } + let mut evidence = self.load_active_order_evidence(current_evidence.order_id())?; + evidence.push(current_evidence); + dedupe_active_order_evidence(&mut evidence); + let Some((raw_order_id, buyer_pubkey)) = evidence + .first() + .map(ActiveOrderEvidence::order_projection_identity) + else { + return Ok(()); }; - self.apply_order_decision(&envelope.payload)?; - Ok(Some(signed_event_projection(record))) + let raw_order_id = raw_order_id.to_owned(); + let buyer_pubkey = buyer_pubkey.to_owned(); + let order_id = projected_order_id(raw_order_id.as_str(), buyer_pubkey.as_str()); + let buckets = ActiveOrderEvidenceBuckets::from_evidence(evidence); + let projection = reduce_active_order_events( + raw_order_id.as_str(), + buckets.requests, + buckets.decisions, + buckets.revision_proposals, + buckets.revision_decisions, + buckets.fulfillments, + buckets.cancellations, + buckets.receipts, + [], + [], + ); + self.apply_active_order_projection(order_id, &projection) } fn upsert_order_request( @@ -1076,11 +1127,7 @@ impl<'a> AppLocalInteropRepository<'a> { farm_id = excluded.farm_id, order_number = excluded.order_number, customer_display_name = excluded.customer_display_name, - status = CASE - WHEN orders.status IN ('scheduled', 'packed', 'completed', 'declined', 'refunded') - THEN orders.status - ELSE excluded.status - END, + status = excluded.status, buyer_context_key = coalesce(orders.buyer_context_key, excluded.buyer_context_key), updated_at = excluded.updated_at", params![ @@ -1099,48 +1146,45 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(order_id) } - fn apply_order_decision( + fn apply_active_order_projection( &self, - payload: &RadrootsTradeOrderDecisionEvent, + order_id: OrderId, + projection: &RadrootsActiveOrderProjection, ) -> Result<(), AppSqliteError> { - let order_id = projected_order_id(payload.order_id.as_str(), payload.buyer_pubkey.as_str()); - match &payload.decision { - RadrootsTradeOrderDecision::Accepted { .. } => { - self.connection - .execute( - "UPDATE orders - SET status = CASE - WHEN status IN ('packed', 'completed', 'declined', 'refunded') THEN status - ELSE 'scheduled' - END, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ?1", - params![order_id.to_string()], - ) - .map_err(|source| AppSqliteError::Query { - operation: "apply local interop order decision", - source, - })?; - } - RadrootsTradeOrderDecision::Declined { .. } => { - self.connection - .execute( - "UPDATE orders - SET status = CASE - WHEN status IN ('packed', 'completed', 'refunded') THEN status - ELSE 'declined' - END, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ?1", - params![order_id.to_string()], - ) - .map_err(|source| AppSqliteError::Query { - operation: "apply local interop order decision", - source, - })?; + let Some(status) = order_status_from_active_projection(projection) else { + return Ok(()); + }; + self.connection + .execute( + "UPDATE orders + SET status = ?2, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ?1", + params![order_id.to_string(), status.storage_key()], + ) + .map_err(|source| AppSqliteError::Query { + operation: "apply local interop active order projection", + source, + })?; + Ok(()) + } + + fn load_active_order_evidence( + &self, + order_id: &str, + ) -> Result<Vec<ActiveOrderEvidence>, AppSqliteError> { + let mut evidence = Vec::new(); + for kind in ACTIVE_ORDER_EVENT_KINDS { + for event in self.load_signed_events_by_kind(kind)? { + let Some(record) = active_order_evidence_from_event(&event) else { + continue; + }; + if record.order_id() == order_id { + evidence.push(record); + } } } - Ok(()) + Ok(evidence) } fn replace_order_request_lines( @@ -2209,6 +2253,121 @@ struct ExistingListingProjection { farm_key: Option<String>, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum ActiveOrderEvidence { + Request(RadrootsActiveOrderRequestRecord), + Decision(RadrootsActiveOrderDecisionRecord), + RevisionProposal(RadrootsActiveOrderRevisionProposalRecord), + RevisionDecision(RadrootsActiveOrderRevisionDecisionRecord), + Fulfillment(RadrootsActiveOrderFulfillmentRecord), + Cancellation(RadrootsActiveOrderCancellationRecord), + Receipt(RadrootsActiveOrderReceiptRecord), +} + +impl ActiveOrderEvidence { + fn event_id(&self) -> &str { + match self { + Self::Request(record) => record.event_id.as_str(), + Self::Decision(record) => record.event_id.as_str(), + Self::RevisionProposal(record) => record.event_id.as_str(), + Self::RevisionDecision(record) => record.event_id.as_str(), + Self::Fulfillment(record) => record.event_id.as_str(), + Self::Cancellation(record) => record.event_id.as_str(), + Self::Receipt(record) => record.event_id.as_str(), + } + } + + fn order_id(&self) -> &str { + match self { + Self::Request(record) => record.payload.order_id.as_str(), + Self::Decision(record) => record.payload.order_id.as_str(), + Self::RevisionProposal(record) => record.payload.order_id.as_str(), + Self::RevisionDecision(record) => record.payload.order_id.as_str(), + Self::Fulfillment(record) => record.payload.order_id.as_str(), + Self::Cancellation(record) => record.payload.order_id.as_str(), + Self::Receipt(record) => record.payload.order_id.as_str(), + } + } + + fn request_payload(&self) -> Option<&RadrootsTradeOrderRequested> { + match self { + Self::Request(record) => Some(&record.payload), + Self::Decision(_) + | Self::RevisionProposal(_) + | Self::RevisionDecision(_) + | Self::Fulfillment(_) + | Self::Cancellation(_) + | Self::Receipt(_) => None, + } + } + + fn order_projection_identity(&self) -> (&str, &str) { + match self { + Self::Request(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::Decision(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::RevisionProposal(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::RevisionDecision(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::Fulfillment(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::Cancellation(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::Receipt(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct ActiveOrderEvidenceBuckets { + requests: Vec<RadrootsActiveOrderRequestRecord>, + decisions: Vec<RadrootsActiveOrderDecisionRecord>, + revision_proposals: Vec<RadrootsActiveOrderRevisionProposalRecord>, + revision_decisions: Vec<RadrootsActiveOrderRevisionDecisionRecord>, + fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>, + cancellations: Vec<RadrootsActiveOrderCancellationRecord>, + receipts: Vec<RadrootsActiveOrderReceiptRecord>, +} + +impl ActiveOrderEvidenceBuckets { + fn from_evidence(evidence: Vec<ActiveOrderEvidence>) -> Self { + let mut buckets = Self::default(); + for record in evidence { + match record { + ActiveOrderEvidence::Request(record) => buckets.requests.push(record), + ActiveOrderEvidence::Decision(record) => buckets.decisions.push(record), + ActiveOrderEvidence::RevisionProposal(record) => { + buckets.revision_proposals.push(record); + } + ActiveOrderEvidence::RevisionDecision(record) => { + buckets.revision_decisions.push(record); + } + ActiveOrderEvidence::Fulfillment(record) => buckets.fulfillments.push(record), + ActiveOrderEvidence::Cancellation(record) => buckets.cancellations.push(record), + ActiveOrderEvidence::Receipt(record) => buckets.receipts.push(record), + } + } + buckets + } +} + fn listing_currentness_row( row: &rusqlite::Row<'_>, ) -> rusqlite::Result<StoredListingCurrentnessEvidence> { @@ -2353,6 +2512,148 @@ fn parse_app_d_tag_uuid(value: &str) -> Option<Uuid> { } } +fn active_order_event_kind(kind: i64) -> bool { + ACTIVE_ORDER_EVENT_KINDS.contains(&kind) +} + +fn active_order_evidence_from_event(event: &RadrootsNostrEvent) -> Option<ActiveOrderEvidence> { + match i64::from(event.kind) { + KIND_ORDER_REQUEST => { + let envelope = active_trade_order_request_from_event(event).ok()?; + Some(ActiveOrderEvidence::Request( + RadrootsActiveOrderRequestRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + payload: envelope.payload, + }, + )) + } + KIND_ORDER_DECISION => { + let envelope = active_trade_order_decision_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::Decision( + RadrootsActiveOrderDecisionRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + KIND_ORDER_REVISION => { + let envelope = active_trade_order_revision_proposal_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::RevisionProposal( + RadrootsActiveOrderRevisionProposalRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + KIND_ORDER_REVISION_DECISION => { + let envelope = active_trade_order_revision_decision_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::RevisionDecision( + RadrootsActiveOrderRevisionDecisionRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + KIND_ORDER_CANCEL => { + let envelope = active_trade_order_cancel_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::Cancellation( + RadrootsActiveOrderCancellationRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + KIND_ORDER_FULFILLMENT => { + let envelope = active_trade_fulfillment_update_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::Fulfillment( + RadrootsActiveOrderFulfillmentRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + KIND_ORDER_RECEIPT => { + let envelope = active_trade_buyer_receipt_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::Receipt( + RadrootsActiveOrderReceiptRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + _ => None, + } +} + +fn dedupe_active_order_evidence(evidence: &mut Vec<ActiveOrderEvidence>) { + evidence.sort_by(|left, right| left.event_id().cmp(right.event_id())); + evidence.dedup_by(|left, right| left.event_id() == right.event_id()); +} + +fn order_status_from_active_projection( + projection: &RadrootsActiveOrderProjection, +) -> Option<OrderStatus> { + match projection.status { + RadrootsActiveOrderStatus::Missing => None, + RadrootsActiveOrderStatus::Requested => Some(OrderStatus::NeedsAction), + RadrootsActiveOrderStatus::Accepted => match projection.fulfillment_status { + Some(RadrootsActiveTradeFulfillmentState::ReadyForPickup) + | Some(RadrootsActiveTradeFulfillmentState::OutForDelivery) + | Some(RadrootsActiveTradeFulfillmentState::Delivered) => Some(OrderStatus::Packed), + Some(RadrootsActiveTradeFulfillmentState::SellerCancelled) => { + Some(OrderStatus::Declined) + } + Some(RadrootsActiveTradeFulfillmentState::Preparing) + | Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled) + | None => Some(OrderStatus::Scheduled), + }, + RadrootsActiveOrderStatus::Declined | RadrootsActiveOrderStatus::Cancelled => { + Some(OrderStatus::Declined) + } + RadrootsActiveOrderStatus::Completed => Some(OrderStatus::Completed), + RadrootsActiveOrderStatus::Disputed | RadrootsActiveOrderStatus::Invalid => { + Some(OrderStatus::NeedsAction) + } + } +} + fn signed_event_projection(record: &LocalEventRecord) -> ProjectionRecord { ProjectionRecord { kind: "signed_event", @@ -2410,6 +2711,27 @@ fn signed_event_from_record( })) } +fn signed_event_record_is_usable(record: &LocalEventRecord) -> bool { + if record.status != LocalRecordStatus::Published + || matches!( + record.outbox_status, + PublishOutboxStatus::Pending | PublishOutboxStatus::Failed + ) + { + return false; + } + let Some(relay_delivery_json) = record.relay_delivery_json.as_ref() else { + return false; + }; + let Ok(relay_delivery) = RelayDeliveryEvidence::from_json_value(relay_delivery_json) else { + return false; + }; + matches!( + relay_delivery.state, + RelayDeliveryState::Acknowledged | RelayDeliveryState::Observed + ) +} + fn signed_event_local_interop_evidence_is_usable( evidence: &StoredLocalInteropSignedEventEvidence, ) -> bool { @@ -2991,14 +3313,24 @@ mod tests { use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, trade::{ - RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, + RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, + RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, + RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, RadrootsTradePricingBasis, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, + RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, + RadrootsTradePricingBasis, }, }; use radroots_events_codec::{ - trade::{active_trade_order_decision_event_build, active_trade_order_request_event_build}, + trade::{ + active_trade_buyer_receipt_event_build, active_trade_fulfillment_update_event_build, + active_trade_order_cancel_event_build, active_trade_order_decision_event_build, + active_trade_order_request_event_build, + active_trade_order_revision_decision_event_build, + active_trade_order_revision_proposal_event_build, + }, wire::WireEventParts, }; use radroots_local_events::{ @@ -3550,6 +3882,101 @@ mod tests { } } + fn revision_proposal_payload( + revision_id: &str, + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + root_event_id: &str, + prev_event_id: &str, + ) -> RadrootsTradeOrderRevisionProposed { + let request = order_request_payload(order_id, listing_addr, buyer_pubkey, seller_pubkey); + RadrootsTradeOrderRevisionProposed { + revision_id: revision_id.to_owned(), + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + root_event_id: root_event_id.to_owned(), + prev_event_id: prev_event_id.to_owned(), + items: request.items, + economics: request.economics, + reason: "seller confirmed updated pickup details".to_owned(), + } + } + + fn revision_decision_payload( + revision_id: &str, + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + root_event_id: &str, + prev_event_id: &str, + decision: RadrootsTradeOrderRevisionDecision, + ) -> RadrootsTradeOrderRevisionDecisionEvent { + RadrootsTradeOrderRevisionDecisionEvent { + revision_id: revision_id.to_owned(), + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + root_event_id: root_event_id.to_owned(), + prev_event_id: prev_event_id.to_owned(), + decision, + } + } + + fn fulfillment_update_payload( + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + status: RadrootsActiveTradeFulfillmentState, + ) -> RadrootsTradeFulfillmentUpdated { + RadrootsTradeFulfillmentUpdated { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + status, + } + } + + fn order_cancel_payload( + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + ) -> RadrootsTradeOrderCancelled { + RadrootsTradeOrderCancelled { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + reason: "buyer changed pickup plan".to_owned(), + } + } + + fn buyer_receipt_payload( + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + received: bool, + ) -> RadrootsTradeBuyerReceipt { + RadrootsTradeBuyerReceipt { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + received, + issue: (!received).then(|| "items need review".to_owned()), + received_at: 1_777_665_700, + } + } + fn event_from_parts(event_id: &str, author: &str, parts: WireEventParts) -> RadrootsNostrEvent { RadrootsNostrEvent { id: event_id.to_owned(), @@ -4113,6 +4540,547 @@ mod tests { } #[test] + fn active_order_fulfillment_and_receipt_project_through_cli_reducer_state() { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; + let seller_pubkey = "seller-pubkey"; + let buyer_pubkey = "app-buyer-pubkey"; + let order_id_raw = "active-lifecycle-order-1"; + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + events + .append_record(&signed_market_listing_record( + "active-lifecycle-listing", + seller_pubkey, + farm_key, + listing_key, + "Lifecycle Eggs", + "9", + "active", + "pickup", + "North barn pickup", + 4_102_444_800, + 4_102_531_200, + LocalRecordStatus::Published, + PublishOutboxStatus::Acknowledged, + )) + .expect("append signed listing"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import signed listing"); + + let request_payload = order_request_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let request_parts = active_trade_order_request_event_build( + &listing_event_ptr("active-lifecycle-listing-event"), + &request_payload, + ) + .expect("build lifecycle order request"); + let request_event = event_from_parts( + "active-lifecycle-request-event", + buyer_pubkey, + request_parts, + ); + events + .append_record(&signed_order_event_record( + "app:signed_event:active-lifecycle:request", + &request_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_lifecycle"), + )) + .expect("append lifecycle order request"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle order request"); + + let order_id = projected_order_id(order_id_raw, buyer_pubkey); + let buyer_context = BuyerContext::account("acct_lifecycle"); + let seller_farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); + let decision_payload = accepted_order_decision_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let decision_parts = active_trade_order_decision_event_build( + request_event.id.as_str(), + request_event.id.as_str(), + &decision_payload, + ) + .expect("build lifecycle order decision"); + let decision_event = event_from_parts( + "active-lifecycle-decision-event", + seller_pubkey, + decision_parts, + ); + events + .append_record(&signed_order_event_record( + "cli:signed_event:active-lifecycle:decision", + &decision_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append lifecycle order decision"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle order decision"); + let seller_orders = app_store + .load_orders_list( + seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load lifecycle seller orders after decision"); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Scheduled); + + let fulfillment_payload = fulfillment_update_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ); + let fulfillment_parts = active_trade_fulfillment_update_event_build( + request_event.id.as_str(), + decision_event.id.as_str(), + &fulfillment_payload, + ) + .expect("build lifecycle fulfillment update"); + let fulfillment_event = event_from_parts( + "active-lifecycle-fulfillment-event", + seller_pubkey, + fulfillment_parts, + ); + events + .append_record(&signed_order_event_record( + "cli:signed_event:active-lifecycle:fulfillment", + &fulfillment_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append lifecycle fulfillment"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle fulfillment"); + let buyer_orders = app_store + .load_buyer_orders(&buyer_context) + .expect("load lifecycle buyer orders after fulfillment"); + let seller_orders = app_store + .load_orders_list( + seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load lifecycle seller orders after fulfillment"); + assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Ready); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Packed); + + let receipt_payload = buyer_receipt_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + true, + ); + let receipt_parts = active_trade_buyer_receipt_event_build( + request_event.id.as_str(), + fulfillment_event.id.as_str(), + &receipt_payload, + ) + .expect("build lifecycle buyer receipt"); + let receipt_event = event_from_parts( + "active-lifecycle-receipt-event", + buyer_pubkey, + receipt_parts, + ); + events + .append_record(&signed_order_event_record( + "app:signed_event:active-lifecycle:receipt", + &receipt_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_lifecycle"), + )) + .expect("append lifecycle buyer receipt"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle buyer receipt"); + let buyer_detail = app_store + .load_buyer_order_detail(&buyer_context, order_id) + .expect("load lifecycle buyer detail") + .expect("lifecycle buyer detail"); + let seller_orders = app_store + .load_orders_list( + seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load lifecycle seller orders after receipt"); + assert_eq!(buyer_detail.status, BuyerOrderStatus::Completed); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Completed); + } + + #[test] + fn active_order_revision_and_cancellation_project_through_cli_reducer_state() { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "EEEEEEEEEEEEEEEEEEEEEE"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; + let seller_pubkey = "seller-pubkey"; + let buyer_pubkey = "app-buyer-pubkey"; + let order_id_raw = "active-revision-order-1"; + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + events + .append_record(&signed_market_listing_record( + "active-revision-listing", + seller_pubkey, + farm_key, + listing_key, + "Revision Eggs", + "9", + "active", + "pickup", + "North barn pickup", + 4_102_444_800, + 4_102_531_200, + LocalRecordStatus::Published, + PublishOutboxStatus::Acknowledged, + )) + .expect("append revision listing"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import revision listing"); + + let request_payload = order_request_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let request_parts = active_trade_order_request_event_build( + &listing_event_ptr("active-revision-listing-event"), + &request_payload, + ) + .expect("build revision order request"); + let request_event = + event_from_parts("active-revision-request-event", buyer_pubkey, request_parts); + events + .append_record(&signed_order_event_record( + "app:signed_event:active-revision:request", + &request_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_revision"), + )) + .expect("append revision order request"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import revision order request"); + + let decision_payload = accepted_order_decision_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let decision_parts = active_trade_order_decision_event_build( + request_event.id.as_str(), + request_event.id.as_str(), + &decision_payload, + ) + .expect("build revision order decision"); + let decision_event = event_from_parts( + "active-revision-decision-event", + seller_pubkey, + decision_parts, + ); + events + .append_record(&signed_order_event_record( + "cli:signed_event:active-revision:decision", + &decision_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append revision order decision"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import revision order decision"); + + let proposal_payload = revision_proposal_payload( + "revision-1", + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + request_event.id.as_str(), + decision_event.id.as_str(), + ); + let proposal_parts = active_trade_order_revision_proposal_event_build( + request_event.id.as_str(), + decision_event.id.as_str(), + &proposal_payload, + ) + .expect("build revision proposal"); + let proposal_event = event_from_parts( + "active-revision-proposal-event", + seller_pubkey, + proposal_parts, + ); + events + .append_record(&signed_order_event_record( + "cli:signed_event:active-revision:proposal", + &proposal_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append revision proposal"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import revision proposal"); + + let seller_farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); + let order_id = projected_order_id(order_id_raw, buyer_pubkey); + let seller_orders = app_store + .load_orders_list( + seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load revision seller orders after proposal"); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Scheduled); + + let revision_decision_payload = revision_decision_payload( + "revision-1", + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + request_event.id.as_str(), + proposal_event.id.as_str(), + RadrootsTradeOrderRevisionDecision::Accepted, + ); + let revision_decision_parts = active_trade_order_revision_decision_event_build( + request_event.id.as_str(), + proposal_event.id.as_str(), + &revision_decision_payload, + ) + .expect("build revision decision"); + let revision_decision_event = event_from_parts( + "active-revision-decision-response-event", + buyer_pubkey, + revision_decision_parts, + ); + events + .append_record(&signed_order_event_record( + "app:signed_event:active-revision:decision-response", + &revision_decision_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_revision"), + )) + .expect("append revision decision"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import revision decision"); + let seller_orders = app_store + .load_orders_list( + seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load revision seller orders after decision"); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Scheduled); + + let cancel_payload = order_cancel_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let cancel_parts = active_trade_order_cancel_event_build( + request_event.id.as_str(), + revision_decision_event.id.as_str(), + &cancel_payload, + ) + .expect("build revision cancellation"); + let cancel_event = + event_from_parts("active-revision-cancel-event", buyer_pubkey, cancel_parts); + events + .append_record(&signed_order_event_record( + "app:signed_event:active-revision:cancel", + &cancel_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_revision"), + )) + .expect("append revision cancellation"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import revision cancellation"); + let buyer_detail = app_store + .load_buyer_order_detail(&BuyerContext::account("acct_revision"), order_id) + .expect("load revision buyer detail") + .expect("revision buyer detail"); + let seller_orders = app_store + .load_orders_list( + seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load revision seller orders after cancellation"); + assert_eq!(buyer_detail.status, BuyerOrderStatus::Declined); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Declined); + } + + #[test] + fn conflicting_order_decisions_project_to_needs_action_deterministically() { + let run_case = |accepted_first: bool| { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "FFFFFFFFFFFFFFFFFFFFFF"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; + let seller_pubkey = "seller-pubkey"; + let buyer_pubkey = "app-buyer-pubkey"; + let order_id_raw = if accepted_first { + "active-conflict-order-accepted-first" + } else { + "active-conflict-order-declined-first" + }; + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + events + .append_record(&signed_market_listing_record( + "active-conflict-listing", + seller_pubkey, + farm_key, + listing_key, + "Conflict Eggs", + "9", + "active", + "pickup", + "North barn pickup", + 4_102_444_800, + 4_102_531_200, + LocalRecordStatus::Published, + PublishOutboxStatus::Acknowledged, + )) + .expect("append conflict listing"); + let request_payload = order_request_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let request_parts = active_trade_order_request_event_build( + &listing_event_ptr("active-conflict-listing-event"), + &request_payload, + ) + .expect("build conflict request"); + let request_event = + event_from_parts("active-conflict-request-event", buyer_pubkey, request_parts); + events + .append_record(&signed_order_event_record( + "app:signed_event:active-conflict:request", + &request_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_conflict"), + )) + .expect("append conflict request"); + let accepted_payload = accepted_order_decision_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let accepted_parts = active_trade_order_decision_event_build( + request_event.id.as_str(), + request_event.id.as_str(), + &accepted_payload, + ) + .expect("build accepted conflict decision"); + let accepted_event = event_from_parts( + "active-conflict-accepted-event", + seller_pubkey, + accepted_parts, + ); + let declined_payload = declined_order_decision_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let declined_parts = active_trade_order_decision_event_build( + request_event.id.as_str(), + request_event.id.as_str(), + &declined_payload, + ) + .expect("build declined conflict decision"); + let declined_event = event_from_parts( + "active-conflict-declined-event", + seller_pubkey, + declined_parts, + ); + let ordered_events = if accepted_first { + [accepted_event, declined_event] + } else { + [declined_event, accepted_event] + }; + for (index, event) in ordered_events.iter().enumerate() { + events + .append_record(&signed_order_event_record( + &format!("cli:signed_event:active-conflict:decision:{index}"), + event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append conflict decision"); + } + + app_store + .import_shared_local_events_from_store(&events) + .expect("import conflicting decisions"); + let order_id = projected_order_id(order_id_raw, buyer_pubkey); + let detail = app_store + .load_order_detail( + deterministic_farm_id(Some(seller_pubkey), farm_key), + order_id, + ) + .expect("load conflict order detail") + .expect("conflict order detail"); + detail.status + }; + + assert_eq!(run_case(true), OrderStatus::NeedsAction); + assert_eq!(run_case(false), OrderStatus::NeedsAction); + } + + #[test] fn malformed_order_event_remains_signed_event_evidence_without_projection() { let app_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store");