sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit c50cc8d53a6e841838c119d150f469e1d5df677b
parent 7048004b20403f22c1c1f160da682b86320f91ff
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 15:36:23 -0700

sdk: harden order status receipts

- add evidence summaries and eligibility metadata
- expose passive off-platform payment handoff state
- compute next action guidance from local projections
- cover status receipt metadata in order runtime tests

Diffstat:
Mcrates/sdk/src/lib.rs | 18+++++++++---------
Mcrates/sdk/src/orders_runtime.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/tests/source_boundary.rs | 4++++
4 files changed, 286 insertions(+), 12 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -69,17 +69,17 @@ pub use crate::orders_runtime::{ OrderDecisionPrepareRequest, OrderDecisionReceipt, OrderEvidenceIngestReceipt, OrderEvidenceIngestRequest, OrderFulfillmentStatusKind, OrderFulfillmentUpdateEnqueueRequest, OrderFulfillmentUpdatePlan, OrderFulfillmentUpdatePrepareRequest, - OrderFulfillmentUpdateReceipt, OrderPaymentStateKind, OrderReceiptRecordEnqueueRequest, - OrderReceiptRecordPlan, OrderReceiptRecordPrepareRequest, OrderReceiptRecordReceipt, - OrderRequestEvidenceIngestReceipt, OrderRequestEvidenceIngestRequest, - OrderRevisionDecisionEnqueueRequest, OrderRevisionDecisionPlan, - OrderRevisionDecisionPrepareRequest, OrderRevisionDecisionReceipt, + OrderFulfillmentUpdateReceipt, OrderPaymentHandoffKind, OrderPaymentStateKind, + OrderReceiptRecordEnqueueRequest, OrderReceiptRecordPlan, OrderReceiptRecordPrepareRequest, + OrderReceiptRecordReceipt, OrderRequestEvidenceIngestReceipt, + OrderRequestEvidenceIngestRequest, OrderRevisionDecisionEnqueueRequest, + OrderRevisionDecisionPlan, OrderRevisionDecisionPrepareRequest, OrderRevisionDecisionReceipt, OrderRevisionProposalEnqueueRequest, OrderRevisionProposalPlan, OrderRevisionProposalPrepareRequest, OrderRevisionProposalReceipt, OrderSettlementStateKind, - OrderStatusKind, OrderStatusReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest, - OrderSubmitPlan, OrderSubmitPrepareRequest, OrderSubmitReceipt, OrderWorkflowEnqueueReceipt, - OrderWorkflowKind, OrderWorkflowPlan, SdkOrderStatusIssue, SdkOrderStatusIssueKind, - SdkOrderStatusSource, + OrderStatusEligibility, OrderStatusEvidenceSummary, OrderStatusKind, OrderStatusNextActionKind, + OrderStatusReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPlan, + OrderSubmitPrepareRequest, OrderSubmitReceipt, OrderWorkflowEnqueueReceipt, OrderWorkflowKind, + OrderWorkflowPlan, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, }; #[cfg(feature = "runtime")] pub use crate::product_clients::{FarmsClient, ListingsClient, OrdersClient, SyncClient}; diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1438,6 +1438,10 @@ pub struct OrderStatusReceipt { pub payment_state: OrderPaymentStateKind, pub settlement_state: OrderSettlementStateKind, pub lifecycle_terminal: bool, + pub evidence: OrderStatusEvidenceSummary, + pub eligibility: OrderStatusEligibility, + pub payment_handoff: OrderPaymentHandoffKind, + pub next_action: OrderStatusNextActionKind, pub event_ids: Vec<RadrootsEventId>, pub request_event_id: Option<RadrootsEventId>, pub decision_event_id: Option<RadrootsEventId>, @@ -1451,6 +1455,61 @@ pub struct OrderStatusReceipt { } #[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderStatusEvidenceSummary { + pub event_count: usize, + pub limit_applied: u32, + pub has_request: bool, + pub has_decision: bool, + pub has_agreement: bool, + pub has_pending_revision: bool, + pub has_fulfillment: bool, + pub has_cancellation: bool, + pub has_receipt: bool, + pub has_issues: bool, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct OrderStatusEligibility { + pub can_decide: bool, + pub can_propose_revision: bool, + pub can_decide_revision: bool, + pub can_cancel: bool, + pub can_update_fulfillment: bool, + pub can_record_receipt: bool, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum OrderPaymentHandoffKind { + NotReady, + NotRequired, + InPersonOrOffPlatformPending, + InPersonOrOffPlatformRecorded, + InPersonOrOffPlatformSettled, + Rejected, + Invalid, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum OrderStatusNextActionKind { + NoLocalOrder, + InspectEvidenceIssues, + AwaitSellerDecision, + ArrangeInPersonOrOffPlatformPayment, + DecideRevision, + FulfillOrder, + RecordReceipt, + Terminal, +} + +#[cfg(feature = "runtime")] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -2596,6 +2655,15 @@ impl OrderStatusReceipt { fn from_query_result(query_result: RadrootsOrderProjectionQueryResult) -> Self { let projection = query_result.projection; let found = projection.status != RadrootsOrderStatus::Missing; + let evidence = OrderStatusEvidenceSummary::from_projection( + &projection, + query_result.event_count, + query_result.limit_applied, + ); + let eligibility = OrderStatusEligibility::from_projection(&projection); + let payment_handoff = OrderPaymentHandoffKind::from_projection(&projection); + let next_action = + OrderStatusNextActionKind::from_projection(&projection, &eligibility, payment_handoff); Self { order_id: projection.order_id, source: SdkOrderStatusSource::LocalEventStore, @@ -2607,6 +2675,10 @@ impl OrderStatusReceipt { payment_state: projection.payment.state.into(), settlement_state: projection.payment.settlement_state.into(), lifecycle_terminal: projection.lifecycle_terminal, + evidence, + eligibility, + payment_handoff, + next_action, event_ids: query_result.event_ids, request_event_id: projection.request_event_id, decision_event_id: projection.decision_event_id, @@ -2622,6 +2694,142 @@ impl OrderStatusReceipt { } #[cfg(feature = "runtime")] +impl OrderStatusEvidenceSummary { + fn from_projection( + projection: &RadrootsOrderProjection, + event_count: usize, + limit_applied: u32, + ) -> Self { + Self { + event_count, + limit_applied, + has_request: projection.request_event_id.is_some(), + has_decision: projection.decision_event_id.is_some(), + has_agreement: projection.agreement_event_id.is_some(), + has_pending_revision: projection.pending_revision_event_id.is_some(), + has_fulfillment: projection.fulfillment_event_id.is_some(), + has_cancellation: projection.cancellation_event_id.is_some(), + has_receipt: projection.receipt_event_id.is_some(), + has_issues: !projection.issues.is_empty(), + } + } +} + +#[cfg(feature = "runtime")] +impl OrderStatusEligibility { + fn from_projection(projection: &RadrootsOrderProjection) -> Self { + let clean = projection.issues.is_empty(); + let open = clean && !projection.lifecycle_terminal; + let requested = projection.status == RadrootsOrderStatus::Requested; + let accepted = projection.status == RadrootsOrderStatus::Accepted; + let has_pending_revision = projection.pending_revision_event_id.is_some(); + let has_fulfillment = projection.fulfillment_event_id.is_some(); + let fulfillment_terminal = matches!( + projection.fulfillment_status, + Some( + RadrootsOrderFulfillmentState::Delivered + | RadrootsOrderFulfillmentState::SellerCancelled + ) + ); + let receipt_ready = matches!( + projection.fulfillment_status, + Some( + RadrootsOrderFulfillmentState::ReadyForPickup + | RadrootsOrderFulfillmentState::Delivered + ) + ); + let revision_payment_open = + projection.payment.state == RadrootsOrderPaymentState::NotRecorded; + + Self { + can_decide: open && requested && projection.decision_event_id.is_none(), + can_propose_revision: open + && accepted + && !has_pending_revision + && !has_fulfillment + && revision_payment_open, + can_decide_revision: open && accepted && has_pending_revision, + can_cancel: open + && matches!( + projection.status, + RadrootsOrderStatus::Requested | RadrootsOrderStatus::Accepted + ) + && !has_pending_revision + && !has_fulfillment, + can_update_fulfillment: open + && accepted + && !has_pending_revision + && !fulfillment_terminal, + can_record_receipt: open + && accepted + && receipt_ready + && projection.receipt_event_id.is_none(), + } + } +} + +#[cfg(feature = "runtime")] +impl OrderPaymentHandoffKind { + fn from_projection(projection: &RadrootsOrderProjection) -> Self { + if !projection.issues.is_empty() || projection.status == RadrootsOrderStatus::Invalid { + return Self::Invalid; + } + match projection.status { + RadrootsOrderStatus::Missing | RadrootsOrderStatus::Requested => Self::NotReady, + RadrootsOrderStatus::Declined | RadrootsOrderStatus::Cancelled => Self::NotRequired, + RadrootsOrderStatus::Accepted + | RadrootsOrderStatus::Completed + | RadrootsOrderStatus::Disputed => match projection.payment.state { + RadrootsOrderPaymentState::NotRecorded => Self::InPersonOrOffPlatformPending, + RadrootsOrderPaymentState::Recorded => Self::InPersonOrOffPlatformRecorded, + RadrootsOrderPaymentState::Settled => Self::InPersonOrOffPlatformSettled, + RadrootsOrderPaymentState::Rejected => Self::Rejected, + RadrootsOrderPaymentState::Invalid => Self::Invalid, + }, + RadrootsOrderStatus::Invalid => Self::Invalid, + } + } +} + +#[cfg(feature = "runtime")] +impl OrderStatusNextActionKind { + fn from_projection( + projection: &RadrootsOrderProjection, + eligibility: &OrderStatusEligibility, + payment_handoff: OrderPaymentHandoffKind, + ) -> Self { + if projection.status == RadrootsOrderStatus::Missing { + return Self::NoLocalOrder; + } + if !projection.issues.is_empty() || projection.status == RadrootsOrderStatus::Invalid { + return Self::InspectEvidenceIssues; + } + if projection.lifecycle_terminal { + return Self::Terminal; + } + if eligibility.can_decide { + return Self::AwaitSellerDecision; + } + if eligibility.can_decide_revision { + return Self::DecideRevision; + } + if eligibility.can_record_receipt { + return Self::RecordReceipt; + } + if matches!( + payment_handoff, + OrderPaymentHandoffKind::InPersonOrOffPlatformPending + ) { + return Self::ArrangeInPersonOrOffPlatformPayment; + } + if eligibility.can_update_fulfillment { + return Self::FulfillOrder; + } + Self::Terminal + } +} + +#[cfg(feature = "runtime")] fn order_submit_plan( actor: &RadrootsActorContext, listing_event: RadrootsNostrEventPtr, diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -41,9 +41,10 @@ use radroots_sdk::{ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, ORDER_SUBMIT_OPERATION_KIND, OrderCancellationEnqueueRequest, OrderDecisionEnqueueRequest, OrderDecisionPrepareRequest, OrderEvidenceIngestRequest, OrderFulfillmentStatusKind, OrderFulfillmentUpdateEnqueueRequest, - OrderPaymentStateKind, OrderReceiptRecordEnqueueRequest, OrderRequestEvidenceIngestRequest, - OrderRevisionDecisionEnqueueRequest, OrderRevisionProposalEnqueueRequest, - OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, OrderSubmitEnqueueRequest, + OrderPaymentHandoffKind, OrderPaymentStateKind, OrderReceiptRecordEnqueueRequest, + OrderRequestEvidenceIngestRequest, OrderRevisionDecisionEnqueueRequest, + OrderRevisionProposalEnqueueRequest, OrderSettlementStateKind, OrderStatusKind, + OrderStatusNextActionKind, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPrepareRequest, OrderWorkflowKind, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkMutationState, SdkOrderStatusIssue, @@ -1952,6 +1953,23 @@ async fn order_revision_order_fulfillment_order_receipt_lifecycle_enqueue_update ); assert_eq!(status.pending_revision_event_id, None); assert!(status.lifecycle_terminal); + assert_eq!( + status.payment_handoff, + OrderPaymentHandoffKind::InPersonOrOffPlatformPending + ); + assert_eq!(status.next_action, OrderStatusNextActionKind::Terminal); + assert!(status.evidence.has_request); + assert!(status.evidence.has_decision); + assert!(status.evidence.has_agreement); + assert!(status.evidence.has_fulfillment); + assert!(status.evidence.has_receipt); + assert!(!status.evidence.has_issues); + assert!(!status.eligibility.can_decide); + assert!(!status.eligibility.can_propose_revision); + assert!(!status.eligibility.can_decide_revision); + assert!(!status.eligibility.can_cancel); + assert!(!status.eligibility.can_update_fulfillment); + assert!(!status.eligibility.can_record_receipt); assert!(status.issues.is_empty()); } @@ -2359,6 +2377,14 @@ async fn order_cancel_lifecycle_enqueue_updates_status() { Some(cancellation.signed_event_id.as_str()) ); assert!(status.lifecycle_terminal); + assert_eq!(status.payment_handoff, OrderPaymentHandoffKind::NotRequired); + assert_eq!(status.next_action, OrderStatusNextActionKind::Terminal); + assert!(status.evidence.has_request); + assert!(status.evidence.has_decision); + assert!(status.evidence.has_cancellation); + assert!(!status.evidence.has_issues); + assert!(!status.eligibility.can_cancel); + assert!(!status.eligibility.can_update_fulfillment); assert!(status.issues.is_empty()); } @@ -2496,6 +2522,15 @@ async fn order_status_returns_not_found_for_missing_local_order() { receipt.settlement_state, OrderSettlementStateKind::NotRequired ); + assert_eq!(receipt.payment_handoff, OrderPaymentHandoffKind::NotReady); + assert_eq!(receipt.next_action, OrderStatusNextActionKind::NoLocalOrder); + assert_eq!(receipt.evidence.event_count, 0); + assert_eq!(receipt.evidence.limit_applied, ORDER_STATUS_DEFAULT_LIMIT); + assert!(!receipt.evidence.has_request); + assert!(!receipt.evidence.has_issues); + assert!(!receipt.eligibility.can_decide); + assert!(!receipt.eligibility.can_cancel); + assert!(!receipt.eligibility.can_update_fulfillment); assert!(receipt.issues.is_empty()); } @@ -2560,6 +2595,13 @@ async fn order_status_contract_dtos_serialize_deterministically() { assert_eq!(receipt_json["status"], "missing"); assert_eq!(receipt_json["payment_state"], "not_recorded"); assert_eq!(receipt_json["settlement_state"], "not_required"); + assert_eq!(receipt_json["payment_handoff"], "not_ready"); + assert_eq!(receipt_json["next_action"], "no_local_order"); + assert_eq!(receipt_json["evidence"]["event_count"], 0); + assert_eq!(receipt_json["evidence"]["limit_applied"], 25); + assert_eq!(receipt_json["evidence"]["has_request"], false); + assert_eq!(receipt_json["eligibility"]["can_decide"], false); + assert_eq!(receipt_json["eligibility"]["can_cancel"], false); let issue = SdkOrderStatusIssue { kind: SdkOrderStatusIssueKind::DecisionPayloadInvalid, @@ -2633,6 +2675,26 @@ async fn order_status_projects_local_request_and_decision_events() { ); assert!(receipt.issues.is_empty()); assert!(!receipt.lifecycle_terminal); + assert_eq!( + receipt.payment_handoff, + OrderPaymentHandoffKind::InPersonOrOffPlatformPending + ); + assert_eq!( + receipt.next_action, + OrderStatusNextActionKind::ArrangeInPersonOrOffPlatformPayment + ); + assert_eq!(receipt.evidence.event_count, 2); + assert!(receipt.evidence.has_request); + assert!(receipt.evidence.has_decision); + assert!(receipt.evidence.has_agreement); + assert!(!receipt.evidence.has_pending_revision); + assert!(!receipt.evidence.has_issues); + assert!(!receipt.eligibility.can_decide); + assert!(receipt.eligibility.can_propose_revision); + assert!(!receipt.eligibility.can_decide_revision); + assert!(receipt.eligibility.can_cancel); + assert!(receipt.eligibility.can_update_fulfillment); + assert!(!receipt.eligibility.can_record_receipt); } #[tokio::test] diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -64,6 +64,7 @@ const REQUIRED_ORDER_RUNTIME_EXPORTS: &[&str] = &[ "OrderFulfillmentUpdatePlan", "OrderFulfillmentUpdatePrepareRequest", "OrderFulfillmentUpdateReceipt", + "OrderPaymentHandoffKind", "OrderPaymentStateKind", "OrderReceiptRecordEnqueueRequest", "OrderReceiptRecordPlan", @@ -80,7 +81,10 @@ const REQUIRED_ORDER_RUNTIME_EXPORTS: &[&str] = &[ "OrderRevisionProposalPrepareRequest", "OrderRevisionProposalReceipt", "OrderSettlementStateKind", + "OrderStatusEligibility", + "OrderStatusEvidenceSummary", "OrderStatusKind", + "OrderStatusNextActionKind", "OrderStatusReceipt", "OrderStatusRequest", "OrderSubmitEnqueueRequest",