app

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

commit 82a25a26ad6d462de6efec5a1408b6186510421f
parent dcfea482483a9a4df60be7551ad92155559772cf
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 19:49:13 -0700

app: make lifecycle preflight payment aware

Diffstat:
Mcrates/desktop/src/runtime.rs | 869++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 778 insertions(+), 91 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -64,7 +64,10 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; -use radroots_events_codec::trade::active_trade_event_context_from_tags; +use radroots_events_codec::trade::{ + active_trade_event_context_from_tags, active_trade_payment_recorded_from_event, + active_trade_settlement_decision_from_event, +}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; use radroots_local_events::{ BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, @@ -100,9 +103,10 @@ use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderPaymentRecord, - RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderRequestRecord, - RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord, - RadrootsActiveOrderSettlementRecord, RadrootsActiveOrderStatus, reduce_active_order_events, + RadrootsActiveOrderPaymentState, RadrootsActiveOrderReceiptRecord, + RadrootsActiveOrderRequestRecord, RadrootsActiveOrderRevisionDecisionRecord, + RadrootsActiveOrderRevisionProposalRecord, RadrootsActiveOrderSettlementRecord, + RadrootsActiveOrderStatus, reduce_active_order_events, }; use serde_json::json; use thiserror::Error; @@ -141,7 +145,7 @@ const APP_DIRECT_RELAY_INGEST_SCOPE_KEY: &str = "direct_relay_ingest"; const APP_DIRECT_RELAY_INGEST_STALE_AFTER_SECONDS: i64 = 900; const APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE: u32 = 250; const APP_DIRECT_RELAY_INGEST_KINDS: &[u16] = &[ - 0, 30340, 30402, 30403, 3422, 3423, 3424, 3425, 3432, 3433, 3434, + 0, 30340, 30402, 30403, 3422, 3423, 3424, 3425, 3432, 3433, 3434, 3435, 3436, ]; #[derive(Debug, Default)] @@ -220,6 +224,7 @@ struct ResolvedAppOrderFulfillmentEvidence { #[derive(Clone, Debug, Eq, PartialEq)] struct ResolvedAppOrderLifecycleEvidence { status: RadrootsActiveOrderStatus, + payment_state: RadrootsActiveOrderPaymentState, agreement_event_id: Option<String>, last_event_id: Option<String>, decision: Option<ResolvedAppOrderDecisionEvidence>, @@ -239,6 +244,8 @@ struct AppActiveOrderEvidenceBuckets { fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>, cancellations: Vec<RadrootsActiveOrderCancellationRecord>, receipts: Vec<RadrootsActiveOrderReceiptRecord>, + payments: Vec<RadrootsActiveOrderPaymentRecord>, + settlements: Vec<RadrootsActiveOrderSettlementRecord>, } #[derive(Debug, Default)] @@ -2750,6 +2757,11 @@ impl DesktopAppRuntimeState { reason: "seller order revision requires accepted order decision evidence", }); } + if active_order_payment_blocks_lifecycle_write(&lifecycle) { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires no recorded or settled payment", + }); + } if lifecycle.cancellation_event_id.is_some() || lifecycle.receipt_event_id.is_some() || lifecycle.latest_fulfillment.is_some() @@ -3034,6 +3046,11 @@ impl DesktopAppRuntimeState { reason: "buyer order cancellation requires an unfulfilled order", }); } + if active_order_payment_blocks_lifecycle_write(&lifecycle) { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires no recorded or settled payment", + }); + } let prev_event_id = match lifecycle.status { RadrootsActiveOrderStatus::Requested => request.request_event_id.clone(), RadrootsActiveOrderStatus::Accepted => active_order_current_parent_event_id( @@ -5518,6 +5535,42 @@ impl DesktopAppRuntimeState { payload: envelope.payload, }); } + 3435 => { + let envelope = + active_trade_payment_recorded_from_event(&event).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "order lifecycle evidence is invalid", + } + })?; + let context = active_order_event_record_context(&event, envelope.message_type)?; + buckets.payments.push(RadrootsActiveOrderPaymentRecord { + event_id: event.id, + author_pubkey: event.author, + counterparty_pubkey: context.0, + root_event_id: context.1, + prev_event_id: context.2, + payload: envelope.payload, + }); + } + 3436 => { + let envelope = + active_trade_settlement_decision_from_event(&event).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "order lifecycle evidence is invalid", + } + })?; + let context = active_order_event_record_context(&event, envelope.message_type)?; + buckets + .settlements + .push(RadrootsActiveOrderSettlementRecord { + event_id: event.id, + author_pubkey: event.author, + counterparty_pubkey: context.0, + root_event_id: context.1, + prev_event_id: context.2, + payload: envelope.payload, + }); + } _ => {} } } @@ -5531,8 +5584,8 @@ impl DesktopAppRuntimeState { buckets.fulfillments.clone(), buckets.cancellations.clone(), buckets.receipts.clone(), - Vec::<RadrootsActiveOrderPaymentRecord>::new(), - Vec::<RadrootsActiveOrderSettlementRecord>::new(), + buckets.payments.clone(), + buckets.settlements.clone(), ); if !projection.issues.is_empty() || projection.status == RadrootsActiveOrderStatus::Invalid { @@ -5583,6 +5636,7 @@ impl DesktopAppRuntimeState { Ok(ResolvedAppOrderLifecycleEvidence { status: projection.status, + payment_state: projection.payment.state, agreement_event_id: projection.agreement_event_id, last_event_id: projection.last_event_id, decision, @@ -5613,7 +5667,7 @@ impl DesktopAppRuntimeState { ) -> Result<Vec<radroots_sdk::RadrootsNostrEvent>, AppSqliteError> { let mut events = Vec::new(); let mut seen_event_ids = BTreeSet::new(); - let kinds = [3423_u32, 3424, 3425, 3432, 3433, 3434]; + let kinds = [3423_u32, 3424, 3425, 3432, 3433, 3434, 3435, 3436]; if let Some(sqlite_store) = self.sqlite_store.as_ref() { for kind in kinds { @@ -9377,6 +9431,15 @@ fn active_order_current_parent_event_id( .ok_or(AppSqliteError::InvalidProjection { reason }) } +fn active_order_payment_blocks_lifecycle_write( + lifecycle: &ResolvedAppOrderLifecycleEvidence, +) -> bool { + matches!( + lifecycle.payment_state, + RadrootsActiveOrderPaymentState::Recorded | RadrootsActiveOrderPaymentState::Settled + ) +} + fn active_order_revision_parent_event_id( lifecycle: &ResolvedAppOrderLifecycleEvidence, ) -> Option<String> { @@ -9709,13 +9772,17 @@ mod tests { use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; - use radroots_identity::RadrootsIdentity; + use radroots_events_codec::trade::{ + active_trade_payment_recorded_event_build, active_trade_settlement_decision_event_build, + }; + use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; use radroots_local_events::{ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, CANONICAL_RELAY_SET_FINGERPRINT_VERSION, LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, canonical_relay_set_fingerprint, }; + use radroots_nostr::prelude::radroots_nostr_build_event; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault, @@ -9729,9 +9796,11 @@ mod tests { RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, - RadrootsTradePricingBasis, + RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, + RadrootsTradeSettlementDecision, RadrootsTradeSettlementDecisionEvent, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; + use radroots_trade::order::radroots_trade_order_economics_digest; use serde_json::json; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -9894,6 +9963,13 @@ mod tests { fn event_count(&self) -> usize { self.events.lock().expect("relay events lock").len() } + + fn push_event(&self, event: &radroots_nostr::prelude::RadrootsNostrEvent) { + self.events + .lock() + .expect("relay events lock") + .push(serde_json::to_value(event).expect("relay event json")); + } } fn relay_event_matches_filters( @@ -15388,6 +15464,198 @@ mod tests { } #[test] + fn runtime_rejects_seller_order_revision_after_recorded_or_settled_payment_evidence() { + for (label, settlement) in [ + ("recorded", None), + ("settled", Some(RadrootsTradeSettlementDecision::Accepted)), + ] { + let relay = ThreadedAckRelay::spawn(); + let runtime_label = format!("seller_order_revision_payment_{label}"); + let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = + seller_order_decision_runtime(runtime_label.as_str(), 6, 2); + install_direct_relay_sync_transport(&runtime, &relay); + let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1"; + let decision_event_id = append_signed_order_decision_record( + &paths, + "seller-order-decision-1", + request_event_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + let payment_event_id = append_signed_payment_record( + &paths, + "seller-order-decision-1", + format!("seller-order-revision-payment-{label}").as_str(), + request_event_id, + decision_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + if let Some(decision) = settlement { + append_signed_settlement_decision_record( + &paths, + "seller-order-decision-1", + format!("seller-order-revision-k3436-{label}").as_str(), + request_event_id, + decision_event_id.as_str(), + payment_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + decision, + ); + } + runtime + .refresh_shared_local_events() + .expect("seller payment evidence should import"); + set_persisted_order_status(&runtime, order_id, "scheduled"); + + let error = runtime + .publish_order_revision_proposal( + order_id, + revision_test_order_items(), + revision_test_order_economics(), + "harvest count updated", + ) + .expect_err("seller revision proposal should reject payment evidence"); + + assert!(matches!( + error, + AppSqliteError::InvalidProjection { + reason: "seller order revision requires no recorded or settled payment" + } + )); + assert_eq!(relay.event_count(), 0); + cleanup_bootstrapped_runtime_paths(&paths); + } + } + + #[test] + fn runtime_publishes_seller_order_revision_after_rejected_settlement_evidence() { + let relay = ThreadedAckRelay::spawn(); + let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = + seller_order_decision_runtime("seller_order_revision_rejected_payment", 6, 2); + install_direct_relay_sync_transport(&runtime, &relay); + let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1"; + let decision_event_id = append_signed_order_decision_record( + &paths, + "seller-order-decision-1", + request_event_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + let payment_event_id = append_signed_payment_record( + &paths, + "seller-order-decision-1", + "seller-order-revision-payment-rejected", + request_event_id, + decision_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + append_signed_settlement_decision_record( + &paths, + "seller-order-decision-1", + "seller-order-revision-k3436-rejected", + request_event_id, + decision_event_id.as_str(), + payment_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + RadrootsTradeSettlementDecision::Rejected, + ); + runtime + .refresh_shared_local_events() + .expect("seller rejected payment evidence should import"); + set_persisted_order_status(&runtime, order_id, "scheduled"); + + assert!( + runtime + .publish_order_revision_proposal( + order_id, + revision_test_order_items(), + revision_test_order_economics(), + "harvest count updated", + ) + .expect("seller revision proposal should publish after rejected 3436") + ); + + assert_eq!(relay.event_count(), 1); + let revision_events = shared_order_events_by_kind(&paths, 3424, seller_pubkey.as_str()); + assert_eq!(revision_events.len(), 1); + assert!(event_has_tag( + revision_events.first().expect("seller revision event"), + "e_prev", + decision_event_id.as_str() + )); + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_rejects_seller_order_revision_with_invalid_payment_evidence() { + let relay = ThreadedAckRelay::spawn(); + let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = + seller_order_decision_runtime("seller_order_revision_invalid_payment", 6, 2); + install_direct_relay_sync_transport(&runtime, &relay); + let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1"; + let decision_event_id = append_signed_order_decision_record( + &paths, + "seller-order-decision-1", + request_event_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + append_signed_payment_record_with_prev( + &paths, + "seller-order-decision-1", + "seller-order-revision-invalid-payment", + request_event_id, + request_event_id, + decision_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + runtime + .refresh_shared_local_events() + .expect("seller invalid payment evidence should import"); + set_persisted_order_status(&runtime, order_id, "scheduled"); + + let error = runtime + .publish_order_revision_proposal( + order_id, + revision_test_order_items(), + revision_test_order_economics(), + "harvest count updated", + ) + .expect_err("seller revision proposal should reject invalid payment evidence"); + + assert_order_lifecycle_evidence_invalid(error); + assert_eq!(relay.event_count(), 0); + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn runtime_places_supported_buyer_order_into_shared_local_events() { let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event"); assert!( @@ -16036,87 +16304,309 @@ mod tests { } #[test] - fn runtime_publishes_linked_buyer_cancellation_from_revision_parent() { - for (label, revision_decision) in [ - ("accepted", RadrootsTradeOrderRevisionDecision::Accepted), - ( - "declined", - RadrootsTradeOrderRevisionDecision::Declined { - reason: "keep original order".to_owned(), - }, - ), + fn runtime_rejects_linked_buyer_cancellation_after_recorded_or_settled_payment_evidence() { + for (label, settlement) in [ + ("recorded", None), + ("settled", Some(RadrootsTradeSettlementDecision::Accepted)), ] { let relay = ThreadedAckRelay::spawn(); - let fixture_label = format!("linked_buyer_order_cancel_revision_{label}"); + let fixture_label = format!("linked_buyer_order_cancel_payment_{label}"); let fixture = linked_buyer_lifecycle_runtime(fixture_label.as_str(), false); - let proposal_key = format!("linked-buyer-order-cancel-revision-{label}-proposal"); - let proposal_event_id = append_signed_order_revision_proposal_record_with_prev( + install_direct_relay_sync_transport(&fixture.runtime, &relay); + let payment_event_id = append_signed_payment_record( &fixture.paths, fixture.trade_order_id.as_str(), - proposal_key.as_str(), + format!("linked-buyer-cancel-payment-{label}").as_str(), fixture.request_event_id.as_str(), fixture.decision_event_id.as_str(), fixture.listing_addr.as_str(), fixture.buyer_pubkey.as_str(), fixture.seller_pubkey.as_str(), + 2, ); - let revision_id = format!("revision-{proposal_key}"); - let revision_decision_event_id = append_signed_order_revision_decision_record_with_prev( - &fixture.paths, - fixture.trade_order_id.as_str(), - format!("linked-buyer-order-cancel-revision-{label}-decision").as_str(), - fixture.request_event_id.as_str(), - proposal_event_id.as_str(), - revision_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - revision_decision, - ); - install_direct_relay_sync_transport(&fixture.runtime, &relay); + if let Some(decision) = settlement { + append_signed_settlement_decision_record( + &fixture.paths, + fixture.trade_order_id.as_str(), + format!("linked-buyer-cancel-k3436-{label}").as_str(), + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + payment_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + 2, + decision, + ); + } fixture .runtime .refresh_shared_local_events() - .expect("linked buyer revision events should import"); + .expect("linked buyer payment evidence should import"); assert!( fixture .runtime .open_personal_order_detail(fixture.order_id) .expect("linked buyer order detail should open") ); - set_persisted_order_status(&fixture.runtime, fixture.order_id, "scheduled"); - assert!( - fixture - .runtime - .publish_buyer_order_cancel(fixture.order_id) - .expect("linked buyer cancellation should publish from revision parent") - ); + let error = fixture + .runtime + .publish_buyer_order_cancel(fixture.order_id) + .expect_err("linked buyer cancellation should reject payment evidence"); - assert_eq!(relay.event_count(), 1); - let cancellation_events = - shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); - assert_eq!(cancellation_events.len(), 1); - let cancellation_event = cancellation_events - .first() - .expect("linked buyer cancellation event"); - assert!(event_has_tag( - cancellation_event, - "e_prev", - revision_decision_event_id.as_str() + assert!(matches!( + error, + AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires no recorded or settled payment" + } )); - + assert_eq!(relay.event_count(), 0); cleanup_bootstrapped_runtime_paths(&fixture.paths); } } #[test] - fn runtime_publishes_linked_buyer_receipt_from_selected_account_nostr_scope() { + fn runtime_publishes_linked_buyer_cancellation_after_rejected_settlement_evidence() { let relay = ThreadedAckRelay::spawn(); - let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_receipt", true); - let fulfillment_event_id = fixture - .fulfillment_event_id - .as_deref() + let fixture = + linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_rejected_payment", false); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + let payment_event_id = append_signed_payment_record( + &fixture.paths, + fixture.trade_order_id.as_str(), + "linked-buyer-cancel-payment-rejected", + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + 2, + ); + append_signed_settlement_decision_record( + &fixture.paths, + fixture.trade_order_id.as_str(), + "linked-buyer-cancel-k3436-rejected", + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + payment_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + 2, + RadrootsTradeSettlementDecision::Rejected, + ); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer rejected payment evidence should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked buyer order detail should open") + ); + + assert!( + fixture + .runtime + .publish_buyer_order_cancel(fixture.order_id) + .expect("linked buyer cancellation should publish after rejected 3436") + ); + + assert_eq!(relay.event_count(), 1); + let cancellation_events = + shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); + assert_eq!(cancellation_events.len(), 1); + assert!(event_has_tag( + cancellation_events + .first() + .expect("linked buyer cancellation event"), + "e_prev", + fixture.decision_event_id.as_str() + )); + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] + fn runtime_rejects_linked_buyer_cancellation_with_invalid_payment_evidence() { + let relay = ThreadedAckRelay::spawn(); + let fixture = + linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_invalid_payment", false); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + append_signed_payment_record_with_prev( + &fixture.paths, + fixture.trade_order_id.as_str(), + "linked-buyer-cancel-invalid-payment", + fixture.request_event_id.as_str(), + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + 2, + ); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer invalid payment evidence should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked buyer order detail should open") + ); + + let error = fixture + .runtime + .publish_buyer_order_cancel(fixture.order_id) + .expect_err("linked buyer cancellation should reject invalid payment evidence"); + + assert_order_lifecycle_evidence_invalid(error); + assert_eq!(relay.event_count(), 0); + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] + fn runtime_rejects_linked_buyer_cancellation_after_relay_payment_evidence() { + let relay = ThreadedAckRelay::spawn(); + let fixture = + linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_relay_payment", false); + configure_runtime_relay_ingest(&fixture.runtime, &relay); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer local events should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked buyer order detail should open") + ); + let buyer_identity = selected_account_signing_identity(&fixture.runtime); + let payment_event = signed_payment_recorded_relay_event( + &buyer_identity, + fixture.trade_order_id.as_str(), + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + 2, + ); + let payment_event_id = payment_event.id.to_hex(); + relay.push_event(&payment_event); + + let error = fixture + .runtime + .publish_buyer_order_cancel(fixture.order_id) + .expect_err("linked buyer cancellation should reject relay payment evidence"); + + assert!(matches!( + error, + AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires no recorded or settled payment" + } + )); + let payment_events = fixture + .runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_local_interop_signed_events_by_kind(3435) + .expect("relay payment evidence should load from local interop"); + assert!( + payment_events + .iter() + .any(|event| event.id == payment_event_id) + ); + assert_eq!(relay.event_count(), 1); + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] + fn runtime_publishes_linked_buyer_cancellation_from_revision_parent() { + for (label, revision_decision) in [ + ("accepted", RadrootsTradeOrderRevisionDecision::Accepted), + ( + "declined", + RadrootsTradeOrderRevisionDecision::Declined { + reason: "keep original order".to_owned(), + }, + ), + ] { + let relay = ThreadedAckRelay::spawn(); + let fixture_label = format!("linked_buyer_order_cancel_revision_{label}"); + let fixture = linked_buyer_lifecycle_runtime(fixture_label.as_str(), false); + let proposal_key = format!("linked-buyer-order-cancel-revision-{label}-proposal"); + let proposal_event_id = append_signed_order_revision_proposal_record_with_prev( + &fixture.paths, + fixture.trade_order_id.as_str(), + proposal_key.as_str(), + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + ); + let revision_id = format!("revision-{proposal_key}"); + let revision_decision_event_id = append_signed_order_revision_decision_record_with_prev( + &fixture.paths, + fixture.trade_order_id.as_str(), + format!("linked-buyer-order-cancel-revision-{label}-decision").as_str(), + fixture.request_event_id.as_str(), + proposal_event_id.as_str(), + revision_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + revision_decision, + ); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer revision events should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked buyer order detail should open") + ); + set_persisted_order_status(&fixture.runtime, fixture.order_id, "scheduled"); + + assert!( + fixture + .runtime + .publish_buyer_order_cancel(fixture.order_id) + .expect("linked buyer cancellation should publish from revision parent") + ); + + assert_eq!(relay.event_count(), 1); + let cancellation_events = + shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); + assert_eq!(cancellation_events.len(), 1); + let cancellation_event = cancellation_events + .first() + .expect("linked buyer cancellation event"); + assert!(event_has_tag( + cancellation_event, + "e_prev", + revision_decision_event_id.as_str() + )); + + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + } + + #[test] + fn runtime_publishes_linked_buyer_receipt_from_selected_account_nostr_scope() { + let relay = ThreadedAckRelay::spawn(); + let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_receipt", true); + let fulfillment_event_id = fixture + .fulfillment_event_id + .as_deref() .expect("ready fixture should include fulfillment event") .to_owned(); install_direct_relay_sync_transport(&fixture.runtime, &relay); @@ -20013,11 +20503,6 @@ mod tests { SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); let store = LocalEventsStore::new(executor); store.migrate_up().expect("migrate shared local events"); - let currency = RadrootsCoreCurrency::USD; - let unit_price_minor_units = 800_u32; - let total_minor_units = unit_price_minor_units - .checked_mul(order_quantity) - .expect("order total should fit"); let order = RadrootsTradeOrderRequested { order_id: trade_order_id.to_owned(), listing_addr: listing_addr.to_owned(), @@ -20027,30 +20512,7 @@ mod tests { bin_id: "seller-order-primary-bin".to_owned(), bin_count: order_quantity, }], - economics: RadrootsTradeOrderEconomics { - quote_id: format!("{trade_order_id}-quote"), - quote_version: 1, - pricing_basis: RadrootsTradePricingBasis::ListingEvent, - currency, - items: vec![RadrootsTradeOrderEconomicItem { - bin_id: "seller-order-primary-bin".to_owned(), - bin_count: order_quantity, - quantity_amount: RadrootsCoreDecimal::from(1u32), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: RadrootsCoreDecimal::from(8u32), - unit_price_currency: currency, - line_subtotal: RadrootsCoreMoney::from_minor_units_u32( - total_minor_units, - currency, - ), - }], - discounts: Vec::new(), - adjustments: Vec::new(), - subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), - discount_total: RadrootsCoreMoney::zero(currency), - adjustment_total: RadrootsCoreMoney::zero(currency), - total: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), - }, + economics: signed_order_request_economics(trade_order_id, order_quantity), }; let parts = radroots_sdk::trade::build_order_request_draft( &RadrootsNostrEventPtr { @@ -20105,6 +20567,39 @@ mod tests { .expect("append signed order request"); } + fn signed_order_request_economics( + trade_order_id: &str, + order_quantity: u32, + ) -> RadrootsTradeOrderEconomics { + let currency = RadrootsCoreCurrency::USD; + let unit_price_minor_units = 800_u32; + let total_minor_units = unit_price_minor_units + .checked_mul(order_quantity) + .expect("order total should fit"); + + RadrootsTradeOrderEconomics { + quote_id: format!("{trade_order_id}-quote"), + quote_version: 1, + pricing_basis: RadrootsTradePricingBasis::ListingEvent, + currency, + items: vec![RadrootsTradeOrderEconomicItem { + bin_id: "seller-order-primary-bin".to_owned(), + bin_count: order_quantity, + quantity_amount: RadrootsCoreDecimal::from(1u32), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: RadrootsCoreDecimal::from(8u32), + unit_price_currency: currency, + line_subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), + discount_total: RadrootsCoreMoney::zero(currency), + adjustment_total: RadrootsCoreMoney::zero(currency), + total: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), + } + } + fn append_signed_order_decision_record( paths: &AppDesktopRuntimePaths, trade_order_id: &str, @@ -20148,6 +20643,198 @@ mod tests { event_id } + fn append_signed_payment_record( + paths: &AppDesktopRuntimePaths, + trade_order_id: &str, + event_key: &str, + request_event_id: &str, + agreement_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_quantity: u32, + ) -> String { + append_signed_payment_record_with_prev( + paths, + trade_order_id, + event_key, + request_event_id, + agreement_event_id, + agreement_event_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + order_quantity, + ) + } + + fn append_signed_payment_record_with_prev( + paths: &AppDesktopRuntimePaths, + trade_order_id: &str, + event_key: &str, + request_event_id: &str, + prev_event_id: &str, + agreement_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_quantity: u32, + ) -> String { + let economics = signed_order_request_economics(trade_order_id, order_quantity); + let payload = RadrootsTradePaymentRecorded { + order_id: trade_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: request_event_id.to_owned(), + previous_event_id: prev_event_id.to_owned(), + agreement_event_id: agreement_event_id.to_owned(), + quote_id: economics.quote_id.clone(), + quote_version: economics.quote_version, + economics_digest: radroots_trade_order_economics_digest(&economics) + .expect("payment economics digest"), + amount: economics.total.amount, + currency: economics.total.currency, + method: RadrootsTradePaymentMethod::ManualTransfer, + reference: Some(format!("memo-{event_key}")), + paid_at: Some(1_774_000_050), + }; + let parts = + active_trade_payment_recorded_event_build(request_event_id, prev_event_id, &payload) + .expect("payment recorded draft should build"); + let record_id = format!("app:signed_event:payment:{event_key}"); + let event_id = format!("event-{record_id}"); + append_trade_signed_event_record( + paths, + record_id.as_str(), + event_id.as_str(), + i64::from(parts.kind), + buyer_pubkey, + listing_addr, + json!(parts.tags), + parts.content, + ); + event_id + } + + fn append_signed_settlement_decision_record( + paths: &AppDesktopRuntimePaths, + trade_order_id: &str, + event_key: &str, + request_event_id: &str, + agreement_event_id: &str, + payment_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_quantity: u32, + decision: RadrootsTradeSettlementDecision, + ) -> String { + let economics = signed_order_request_economics(trade_order_id, order_quantity); + let payload = RadrootsTradeSettlementDecisionEvent { + order_id: trade_order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + root_event_id: request_event_id.to_owned(), + previous_event_id: payment_event_id.to_owned(), + agreement_event_id: agreement_event_id.to_owned(), + payment_event_id: payment_event_id.to_owned(), + quote_id: economics.quote_id.clone(), + quote_version: economics.quote_version, + economics_digest: radroots_trade_order_economics_digest(&economics) + .expect("k3436 economics digest"), + amount: economics.total.amount, + currency: economics.total.currency, + decision, + reason: (decision == RadrootsTradeSettlementDecision::Rejected) + .then(|| "reference mismatch".to_owned()), + }; + let parts = active_trade_settlement_decision_event_build( + request_event_id, + payment_event_id, + &payload, + ) + .expect("k3436 draft should build"); + let record_id = format!("app:signed_event:k3436:{event_key}"); + let event_id = format!("event-{record_id}"); + append_trade_signed_event_record( + paths, + record_id.as_str(), + event_id.as_str(), + i64::from(parts.kind), + seller_pubkey, + listing_addr, + json!(parts.tags), + parts.content, + ); + event_id + } + + fn signed_payment_recorded_relay_event( + buyer: &RadrootsIdentity, + trade_order_id: &str, + request_event_id: &str, + agreement_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_quantity: u32, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let economics = signed_order_request_economics(trade_order_id, order_quantity); + let payload = RadrootsTradePaymentRecorded { + order_id: trade_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: request_event_id.to_owned(), + previous_event_id: agreement_event_id.to_owned(), + agreement_event_id: agreement_event_id.to_owned(), + quote_id: economics.quote_id.clone(), + quote_version: economics.quote_version, + economics_digest: radroots_trade_order_economics_digest(&economics) + .expect("relay payment economics digest"), + amount: economics.total.amount, + currency: economics.total.currency, + method: RadrootsTradePaymentMethod::ManualTransfer, + reference: Some("relay-memo-1".to_owned()), + paid_at: Some(1_774_000_050), + }; + let parts = active_trade_payment_recorded_event_build( + request_event_id, + agreement_event_id, + &payload, + ) + .expect("relay payment draft should build"); + + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("relay payment builder") + .sign_with_keys(buyer.keys()) + .expect("relay payment should sign") + } + + fn selected_account_signing_identity(runtime: &DesktopAppRuntime) -> RadrootsIdentity { + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + let account_id = + RadrootsIdentityId::parse(account_id.as_str()).expect("selected account id"); + runtime + .lock_state() + .accounts_manager + .as_ref() + .expect("accounts manager") + .get_signing_identity(&account_id) + .expect("signer lookup should succeed") + .expect("selected account should have local signer") + } + fn append_signed_order_fulfillment_record( paths: &AppDesktopRuntimePaths, trade_order_id: &str,