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:
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",