commit b33ce0171c1fb34ae574375b054d9f81a8d08e00
parent 49e50ffad41bf0218c8e65dc92a056cf9f971d7a
Author: triesap <tyson@radroots.org>
Date: Tue, 26 May 2026 01:54:13 +0000
orders: prepare seller decision payloads
Diffstat:
6 files changed, 1272 insertions(+), 30 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -19,15 +19,15 @@ use radroots_app_models::{
FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft,
FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId,
LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrderRecoveryProjection,
- OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayBatchPrintStatus,
- PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind,
- PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus, PackDayProjection,
- PackDayScreenQueryState, PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId,
- ProductStatus, ProductsFilter, ProductsListProjection, ProductsSort, RecoveryKind,
- RecoveryQueueProjection, RecoveryRecordId, RecoveryState, ReminderDeadlineProjection,
- ReminderDeliveryState, ReminderFeedProjection, ReminderId, ReminderKind,
- ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency,
- SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
+ OrderStatus, OrdersFilter, OrdersListProjection, OrdersScreenQueryState,
+ PackDayBatchPrintStatus, PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus,
+ PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus,
+ PackDayProjection, PackDayScreenQueryState, PersonalSection, PickupLocationRecord,
+ ProductEditorDraft, ProductId, ProductStatus, ProductsFilter, ProductsListProjection,
+ ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState,
+ ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId,
+ ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface,
+ ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
TodayAgendaProjection,
};
use radroots_app_remote_signer::{
@@ -36,8 +36,8 @@ use radroots_app_remote_signer::{
use radroots_app_sqlite::{
APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSqliteError, AppSqliteStore,
BuyerOrderLocalEventExport, BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome,
- DatabaseTarget, StoredPendingSyncOperation, StoredRelayIngestCursor, StoredSyncConflict,
- derive_farm_rules_readiness,
+ DatabaseTarget, SellerOrderDecisionExport, StoredPendingSyncOperation, StoredRelayIngestCursor,
+ StoredSyncConflict, derive_farm_rules_readiness, projected_order_id_from_trade_request,
};
use radroots_app_state::{
APP_STATE_FILE_NAME, AppShellProjection, AppStateCommand, AppStatePersistenceRepository,
@@ -50,7 +50,8 @@ use radroots_app_state::{
derive_sync_projection,
};
use radroots_app_sync::{
- AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload,
+ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionInventoryCommitment,
+ AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderRequestItemPayload,
AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload,
AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest,
AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation,
@@ -80,7 +81,10 @@ use radroots_sdk::listing::{
RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
RadrootsListingStatus,
};
-use radroots_sdk::trade::RadrootsTradeOrderRequested;
+use radroots_sdk::trade::{
+ RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent,
+ RadrootsTradeOrderRequested,
+};
use radroots_sdk::{
RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment,
SdkPublishReceipt, SdkTransportMode, SdkTransportReceipt, SignerConfig,
@@ -121,6 +125,7 @@ const APP_DIRECT_RELAY_INGEST_LIMIT: usize = 1_000;
const APP_DIRECT_RELAY_INGEST_MAX_PAGES: usize = 5;
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,
];
@@ -160,6 +165,19 @@ struct AppDirectRelayFetchedRelay {
last_event_created_at_unix_seconds: Option<i64>,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AppSellerOrderDecisionCommand {
+ Accept,
+ Decline { reason: String },
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct ResolvedAppSellerOrderRequest {
+ request_event_id: String,
+ listing_event_id: Option<String>,
+ payload: RadrootsTradeOrderRequested,
+}
+
#[derive(Debug, Default)]
struct AppDirectRelayIngestReport {
local_import: AppLocalInteropImportReport,
@@ -321,6 +339,7 @@ fn publish_payload_context(publish_payload: &AppPublishPayload) -> &AppPublishCo
AppPublishPayload::FarmProfile(payload) => &payload.context,
AppPublishPayload::Listing(payload) => &payload.context,
AppPublishPayload::OrderRequest(payload) => &payload.context,
+ AppPublishPayload::OrderDecision(payload) => &payload.context,
}
}
@@ -661,6 +680,27 @@ impl DesktopAppRuntime {
self.lock_state_mut().mark_order_completed(order_id)
}
+ pub fn prepare_order_accept(
+ &self,
+ order_id: OrderId,
+ ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> {
+ self.lock_state_mut()
+ .prepare_seller_order_decision(order_id, AppSellerOrderDecisionCommand::Accept)
+ }
+
+ pub fn prepare_order_decline(
+ &self,
+ order_id: OrderId,
+ reason: &str,
+ ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> {
+ self.lock_state_mut().prepare_seller_order_decision(
+ order_id,
+ AppSellerOrderDecisionCommand::Decline {
+ reason: reason.to_owned(),
+ },
+ )
+ }
+
pub fn start_order_recovery(
&self,
order_id: OrderId,
@@ -2230,6 +2270,114 @@ impl DesktopAppRuntimeState {
Ok(updated || context_changed || pending_changed)
}
+ fn prepare_seller_order_decision(
+ &mut self,
+ order_id: OrderId,
+ command: AppSellerOrderDecisionCommand,
+ ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> {
+ let _ = self.import_shared_local_events()?;
+ let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| {
+ AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires valid configured relays",
+ }
+ })?;
+ if relay_urls.is_empty() {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires configured relays",
+ });
+ }
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires local state",
+ });
+ };
+ let Some(farm_id) = self.selected_farm_id() else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires a selected farm",
+ });
+ };
+ let Some(selected_account) = self
+ .state_store
+ .identity_projection()
+ .selected_account
+ .as_ref()
+ else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires a selected seller account",
+ });
+ };
+ let account_id = selected_account.account.account_id.clone();
+ let seller_pubkey = self.local_events_owner_pubkey(selected_account).ok_or(
+ AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires a selected seller public key",
+ },
+ )?;
+ let Some(order_export) =
+ sqlite_store.load_seller_order_decision_export(farm_id, order_id)?
+ else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires a visible seller order",
+ });
+ };
+ if order_export.status != OrderStatus::NeedsAction {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires an undecided order",
+ });
+ }
+ let request = self.resolve_seller_order_request_from_shared_events(order_id)?;
+ if request.payload.seller_pubkey.trim() != seller_pubkey.as_str() {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision seller account does not match order seller",
+ });
+ }
+ let listing_address =
+ radroots_sdk::trade::parse_listing_address(request.payload.listing_addr.as_str())
+ .map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "seller order decision listing address is invalid",
+ })?;
+ if listing_address.seller_pubkey != seller_pubkey {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision listing address is outside seller authority",
+ });
+ }
+
+ let decision = match command {
+ AppSellerOrderDecisionCommand::Accept => AppOrderDecisionPayload::Accepted {
+ inventory_commitments: seller_order_inventory_commitments(&order_export)?,
+ },
+ AppSellerOrderDecisionCommand::Decline { reason } => {
+ let reason = reason.trim();
+ if reason.is_empty() {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decline requires a non-empty reason",
+ });
+ }
+ AppOrderDecisionPayload::Declined {
+ reason: reason.to_owned(),
+ }
+ }
+ };
+ let payload = AppOrderDecisionPublishPayload {
+ context: AppPublishContext::new(account_id, "seller_order_decision"),
+ app_order_id: order_id,
+ farm_id,
+ trade_order_id: request.payload.order_id.clone(),
+ request_event_id: request.request_event_id,
+ listing_event_id: request.listing_event_id,
+ listing_addr: request.payload.listing_addr,
+ buyer_pubkey: request.payload.buyer_pubkey,
+ seller_pubkey: request.payload.seller_pubkey,
+ decision,
+ };
+ AppPublishPayload::OrderDecision(payload.clone())
+ .validate()
+ .map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "seller order decision publish payload is invalid",
+ })?;
+
+ Ok(payload)
+ }
+
fn start_order_recovery(
&mut self,
order_id: OrderId,
@@ -4280,6 +4428,124 @@ impl DesktopAppRuntimeState {
sqlite_store.import_shared_local_events_from_path(database_path.as_path())
}
+ fn resolve_seller_order_request_from_shared_events(
+ &self,
+ order_id: OrderId,
+ ) -> Result<ResolvedAppSellerOrderRequest, AppSqliteError> {
+ let store = self.open_shared_local_events_store()?;
+ let Some(store) = store else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires shared signed order request evidence",
+ });
+ };
+ let mut matched_request = None;
+ let mut before = None;
+
+ loop {
+ let records = match before {
+ Some((before_change_seq, before_seq)) => store
+ .list_records_changed_before(
+ before_change_seq,
+ before_seq,
+ APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE,
+ )
+ .map_err(|source| AppSqliteError::LocalEvents {
+ operation: "load shared order request evidence",
+ source,
+ })?,
+ None => store
+ .list_records_changed_latest(APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE)
+ .map_err(|source| AppSqliteError::LocalEvents {
+ operation: "load shared order request evidence",
+ source,
+ })?,
+ };
+ if records.is_empty() {
+ break;
+ }
+ let is_last_page =
+ records.len() < APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE as usize;
+ before = records.last().map(|record| (record.change_seq, record.seq));
+
+ for record in records {
+ if record.family != LocalRecordFamily::SignedEvent
+ || record.event_kind
+ != Some(i64::from(
+ radroots_sdk::trade::RadrootsActiveTradeMessageType::TradeOrderRequested
+ .kind(),
+ ))
+ {
+ continue;
+ }
+ let Some(event) = signed_event_from_local_record(&record)? else {
+ continue;
+ };
+ let Ok(envelope) = radroots_sdk::trade::parse_order_request(&event) else {
+ continue;
+ };
+ let app_order_id = projected_order_id_from_trade_request(
+ envelope.payload.order_id.as_str(),
+ envelope.payload.buyer_pubkey.as_str(),
+ );
+ if app_order_id != order_id {
+ continue;
+ }
+ if matched_request.is_some() {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision found multiple signed order requests",
+ });
+ }
+ matched_request = Some(ResolvedAppSellerOrderRequest {
+ request_event_id: event.id,
+ listing_event_id: listing_event_id_from_tags(&event.tags),
+ payload: envelope.payload,
+ });
+ }
+
+ if before.is_none() || is_last_page {
+ break;
+ }
+ }
+
+ matched_request.ok_or(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires signed order request evidence",
+ })
+ }
+
+ fn open_shared_local_events_store(
+ &self,
+ ) -> Result<Option<LocalEventsStore<SqliteExecutor>>, AppSqliteError> {
+ let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else {
+ return Ok(None);
+ };
+ let Some(database_path) =
+ shared_local_events_database_path_from_shared_accounts(shared_accounts_paths)
+ else {
+ return Ok(None);
+ };
+ if let Some(parent) = database_path.parent() {
+ fs::create_dir_all(parent).map_err(|source| AppSqliteError::CreateParentDirectory {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ let executor = SqliteExecutor::open(database_path.as_path()).map_err(|source| {
+ AppSqliteError::LocalEventsSql {
+ operation: "open shared local events database",
+ source,
+ }
+ })?;
+ let store = LocalEventsStore::new(executor);
+ store
+ .migrate_up()
+ .map_err(|source| AppSqliteError::LocalEventsSql {
+ operation: "migrate shared local events database",
+ source,
+ })?;
+
+ Ok(Some(store))
+ }
+
fn append_app_farm_local_work_record(
&self,
account: &radroots_app_models::SelectedAccountProjection,
@@ -5740,6 +6006,12 @@ async fn publish_app_payload(
.await
.map_err(|error| AppSyncTransportError::failed(error.to_string()))
}
+ AppPublishPayload::OrderDecision(payload) => {
+ let _decision = order_decision_publish_payload_to_sdk_decision(payload);
+ Err(AppSyncTransportError::failed(
+ "order decision direct relay publish is not wired",
+ ))
+ }
}
}
@@ -6020,6 +6292,10 @@ fn published_operation_receipt(
payload.context.account_id.clone(),
payload.context.source_local_event_id.clone(),
),
+ AppPublishPayload::OrderDecision(payload) => (
+ payload.context.account_id.clone(),
+ payload.context.source_local_event_id.clone(),
+ ),
};
let failed_relays = relay_receipt
.failed_relays
@@ -7712,6 +7988,159 @@ fn current_utc_timestamp() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
+fn signed_event_from_local_record(
+ record: &LocalEventRecord,
+) -> Result<Option<radroots_sdk::RadrootsNostrEvent>, AppSqliteError> {
+ let Some(id) = record.event_id.as_deref().map(str::trim) else {
+ return Ok(None);
+ };
+ let Some(author) = record.event_pubkey.as_deref().map(str::trim) else {
+ return Ok(None);
+ };
+ let Some(kind) = record.event_kind else {
+ return Ok(None);
+ };
+ let Some(content) = record.event_content.as_ref() else {
+ return Ok(None);
+ };
+ let Some(sig) = record.event_sig.as_deref().map(str::trim) else {
+ return Ok(None);
+ };
+ let created_at = record.event_created_at.unwrap_or_default();
+ let created_at = u32::try_from(created_at).map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "signed local event created_at must fit u32",
+ })?;
+ let kind = u32::try_from(kind).map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "signed local event kind must fit u32",
+ })?;
+
+ Ok(Some(radroots_sdk::RadrootsNostrEvent {
+ id: id.to_owned(),
+ author: author.to_owned(),
+ created_at,
+ kind,
+ tags: event_tags_from_value(record.event_tags_json.as_ref())?,
+ content: content.clone(),
+ sig: sig.to_owned(),
+ }))
+}
+
+fn event_tags_from_value(
+ value: Option<&serde_json::Value>,
+) -> Result<Vec<Vec<String>>, AppSqliteError> {
+ let Some(value) = value else {
+ return Ok(Vec::new());
+ };
+ let Some(tags) = value.as_array() else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "signed local event tags must be an array",
+ });
+ };
+
+ tags.iter()
+ .map(|tag| {
+ let Some(values) = tag.as_array() else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "signed local event tag must be an array",
+ });
+ };
+ values
+ .iter()
+ .map(|value| {
+ value
+ .as_str()
+ .map(str::to_owned)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "signed local event tag values must be strings",
+ })
+ })
+ .collect()
+ })
+ .collect()
+}
+
+fn listing_event_id_from_tags(tags: &[Vec<String>]) -> Option<String> {
+ tags.iter().find_map(|tag| {
+ if tag.first().map(String::as_str) == Some("listing_event") {
+ tag.get(1)
+ .map(String::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned)
+ } else {
+ None
+ }
+ })
+}
+
+fn seller_order_inventory_commitments(
+ order: &SellerOrderDecisionExport,
+) -> Result<Vec<AppOrderDecisionInventoryCommitment>, AppSqliteError> {
+ if order.lines.is_empty() {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires order lines",
+ });
+ }
+
+ order
+ .lines
+ .iter()
+ .map(|line| {
+ let bin_id =
+ line.listing_bin_id
+ .as_deref()
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires listing bin evidence",
+ })?;
+ let stock_count = line.stock_count.ok_or(AppSqliteError::InvalidProjection {
+ reason: "seller order decision requires current product stock",
+ })?;
+ let available_quantity = stock_count.checked_sub(line.reserved_quantity).ok_or(
+ AppSqliteError::InvalidProjection {
+ reason: "seller order decision inventory is over-reserved",
+ },
+ )?;
+ if line.quantity > available_quantity {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision would over-reserve inventory",
+ });
+ }
+
+ Ok(AppOrderDecisionInventoryCommitment {
+ bin_id: bin_id.to_owned(),
+ bin_count: line.quantity,
+ })
+ })
+ .collect()
+}
+
+fn order_decision_publish_payload_to_sdk_decision(
+ payload: &AppOrderDecisionPublishPayload,
+) -> RadrootsTradeOrderDecisionEvent {
+ RadrootsTradeOrderDecisionEvent {
+ order_id: payload.trade_order_id.clone(),
+ listing_addr: payload.listing_addr.clone(),
+ buyer_pubkey: payload.buyer_pubkey.clone(),
+ seller_pubkey: payload.seller_pubkey.clone(),
+ decision: match &payload.decision {
+ AppOrderDecisionPayload::Accepted {
+ inventory_commitments,
+ } => RadrootsTradeOrderDecision::Accepted {
+ inventory_commitments: inventory_commitments
+ .iter()
+ .map(|commitment| RadrootsTradeInventoryCommitment {
+ bin_id: commitment.bin_id.clone(),
+ bin_count: commitment.bin_count,
+ })
+ .collect(),
+ },
+ AppOrderDecisionPayload::Declined { reason } => RadrootsTradeOrderDecision::Declined {
+ reason: reason.clone(),
+ },
+ },
+ }
+}
+
fn pending_sync_upsert(aggregate: SyncAggregateRef, payload_json: String) -> PendingSyncOperation {
let created_at = current_utc_timestamp();
@@ -7800,7 +8229,7 @@ mod tests {
};
use radroots_app_sqlite::{
AppSqliteError, AppSqliteStore, BuyerOrderCoordinationState, DatabaseTarget,
- latest_schema_version,
+ latest_schema_version, projected_order_id_from_trade_request,
};
use radroots_app_state::{
APP_STATE_FILE_NAME, AppStateCommand, AppStatePersistenceRepository, AppStateRepository,
@@ -7808,14 +8237,17 @@ mod tests {
HomeRoute,
};
use radroots_app_sync::{
- AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload,
- AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload,
- AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, AppRelayIngestScopeStatus,
- AppSyncRequest, AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError,
- PendingSyncOperation, PendingSyncOperationState, RecordedAppSyncTransport,
- SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus, SyncConflict,
- SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncOperationKind,
- SyncTrigger,
+ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionPayload,
+ AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppPublishContext,
+ AppPublishPayload, AppPublishedOperationReceipt, AppRelayIngestScopeFreshness,
+ AppRelayIngestScopeStatus, AppSyncRequest, AppSyncResult, AppSyncRunStatus,
+ AppSyncTransport, AppSyncTransportError, PendingSyncOperation, PendingSyncOperationState,
+ RecordedAppSyncTransport, SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus,
+ SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity,
+ SyncOperationKind, SyncTrigger,
+ };
+ use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
};
use radroots_identity::RadrootsIdentity;
use radroots_local_events::{
@@ -7828,6 +8260,11 @@ mod tests {
RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault,
account_secret_slot,
};
+ use radroots_sdk::RadrootsNostrEventPtr;
+ use radroots_sdk::trade::{
+ RadrootsTradeOrderDecision, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics,
+ RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradePricingBasis,
+ };
use radroots_sql_core::SqliteExecutor;
use serde_json::json;
use tokio::net::TcpListener;
@@ -7841,7 +8278,8 @@ mod tests {
DesktopAppRuntimeCommandError, DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeState,
DesktopAppSyncStatusSummary, DesktopRemoteSignerPaths, SYNC_TRANSPORT_UNAVAILABLE_MESSAGE,
SdkDirectRelayAppSyncTransport, TokioRuntimeBuilder, default_sync_transport,
- farm_sync_payload, is_hex_64, pending_sync_upsert,
+ farm_sync_payload, is_hex_64, order_decision_publish_payload_to_sdk_decision,
+ pending_sync_upsert,
};
use crate::pack_day_host_handoff::PackDayHostHandoffError;
use crate::pack_day_print::{
@@ -12202,6 +12640,121 @@ mod tests {
}
#[test]
+ fn runtime_prepares_seller_order_accept_payload_from_signed_request() {
+ let (runtime, paths, order_id, _product_id, seller_pubkey, buyer_pubkey) =
+ seller_order_decision_runtime("seller_order_accept_payload", 6, 2);
+
+ let payload = runtime
+ .prepare_order_accept(order_id)
+ .expect("seller order accept payload should prepare");
+ let decision = order_decision_publish_payload_to_sdk_decision(&payload);
+
+ assert_eq!(payload.app_order_id, order_id);
+ assert_eq!(payload.trade_order_id, "seller-order-decision-1");
+ assert_eq!(
+ payload.request_event_id,
+ "event-app:signed_event:order-request:seller-order-decision-1"
+ );
+ assert_eq!(
+ payload.listing_event_id.as_deref(),
+ Some("event-app:signed_event:listing:seller-order-decision")
+ );
+ assert_eq!(payload.buyer_pubkey, buyer_pubkey);
+ assert_eq!(payload.seller_pubkey, seller_pubkey);
+ assert_eq!(decision.order_id, "seller-order-decision-1");
+ let RadrootsTradeOrderDecision::Accepted {
+ inventory_commitments,
+ } = decision.decision
+ else {
+ panic!("expected accepted decision");
+ };
+ assert_eq!(inventory_commitments.len(), 1);
+ assert_eq!(inventory_commitments[0].bin_id, "seller-order-primary-bin");
+ assert_eq!(inventory_commitments[0].bin_count, 2);
+
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
+ fn runtime_prepares_seller_order_decline_payload_with_trimmed_reason() {
+ let (runtime, paths, order_id, _product_id, seller_pubkey, buyer_pubkey) =
+ seller_order_decision_runtime("seller_order_decline_payload", 6, 2);
+
+ let payload = runtime
+ .prepare_order_decline(order_id, " out of stock ")
+ .expect("seller order decline payload should prepare");
+ let decision = order_decision_publish_payload_to_sdk_decision(&payload);
+
+ assert_eq!(payload.buyer_pubkey, buyer_pubkey);
+ assert_eq!(payload.seller_pubkey, seller_pubkey);
+ assert_eq!(
+ payload.decision,
+ AppOrderDecisionPayload::Declined {
+ reason: "out of stock".to_owned()
+ }
+ );
+ let RadrootsTradeOrderDecision::Declined { reason } = decision.decision else {
+ panic!("expected declined decision");
+ };
+ assert_eq!(reason, "out of stock");
+
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
+ fn runtime_finds_seller_order_request_evidence_past_first_local_events_page() {
+ let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) =
+ seller_order_decision_runtime("seller_order_old_request_evidence", 6, 2);
+ append_unrelated_signed_event_records(&paths, 1_005);
+
+ let payload = runtime
+ .prepare_order_accept(order_id)
+ .expect("seller order accept payload should prepare from older evidence");
+
+ assert_eq!(payload.trade_order_id, "seller-order-decision-1");
+ assert_eq!(
+ payload.request_event_id,
+ "event-app:signed_event:order-request:seller-order-decision-1"
+ );
+
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
+ fn runtime_rejects_seller_order_decision_for_wrong_selected_account() {
+ let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) =
+ seller_order_decision_runtime("seller_order_wrong_account", 6, 2);
+ assert!(
+ runtime
+ .generate_local_account(Some("Other seller".to_owned()))
+ .expect("other account should generate")
+ );
+ runtime.lock_state_mut().nostr_relay_urls = vec!["wss://relay.example".to_owned()];
+
+ let error = runtime
+ .prepare_order_accept(order_id)
+ .expect_err("wrong seller account should fail preflight");
+
+ assert!(matches!(error, AppSqliteError::InvalidProjection { .. }));
+
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
+ fn runtime_rejects_seller_order_accept_that_would_over_reserve_inventory() {
+ let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) =
+ seller_order_decision_runtime("seller_order_over_reserved", 1, 2);
+
+ let error = runtime
+ .prepare_order_accept(order_id)
+ .expect_err("over-reserved seller order should fail preflight");
+
+ assert!(matches!(error, AppSqliteError::InvalidProjection { .. }));
+
+ 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!(
@@ -16027,6 +16580,328 @@ mod tests {
.expect("append signed buyer listing");
}
+ fn seller_order_decision_runtime(
+ label: &str,
+ stock_count: u32,
+ order_quantity: u32,
+ ) -> (
+ DesktopAppRuntime,
+ AppDesktopRuntimePaths,
+ OrderId,
+ ProductId,
+ String,
+ String,
+ ) {
+ let (runtime, paths) = bootstrapped_runtime(label);
+ let (account_id, farm_id) = provision_ready_farmer_account(&runtime);
+ runtime.lock_state_mut().nostr_relay_urls = vec!["wss://relay.example".to_owned()];
+ let seller_pubkey = runtime
+ .lock_state()
+ .accounts_manager
+ .as_ref()
+ .expect("accounts manager")
+ .resolve_account_selector(account_id.as_str())
+ .expect("selected seller account should resolve")
+ .public_identity
+ .public_key_hex;
+ let buyer_pubkey =
+ "1111111111111111111111111111111111111111111111111111111111111111".to_owned();
+ let product_id = ProductId::new();
+ let trade_order_id = "seller-order-decision-1";
+ let order_id = projected_order_id_from_trade_request(trade_order_id, buyer_pubkey.as_str());
+ let farm_key = super::d_tag_from_uuid(farm_id.as_uuid());
+ let listing_key = super::d_tag_from_uuid(product_id.as_uuid());
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_key}");
+ let listing_event_id = "event-app:signed_event:listing:seller-order-decision";
+ append_app_signed_listing_record(
+ &paths,
+ account_id.as_str(),
+ seller_pubkey.as_str(),
+ farm_key.as_str(),
+ listing_key.as_str(),
+ listing_event_id,
+ stock_count,
+ );
+ append_signed_order_request_record(
+ &paths,
+ trade_order_id,
+ listing_addr.as_str(),
+ listing_event_id,
+ buyer_pubkey.as_str(),
+ seller_pubkey.as_str(),
+ order_quantity,
+ );
+
+ (
+ runtime,
+ paths,
+ order_id,
+ product_id,
+ seller_pubkey,
+ buyer_pubkey,
+ )
+ }
+
+ fn append_app_signed_listing_record(
+ paths: &AppDesktopRuntimePaths,
+ account_id: &str,
+ seller_pubkey: &str,
+ farm_key: &str,
+ listing_key: &str,
+ listing_event_id: &str,
+ stock_count: u32,
+ ) {
+ let database_path = paths
+ .shared_local_events_database_path()
+ .expect("shared local events path");
+ if let Some(parent) = database_path.parent() {
+ fs::create_dir_all(parent).expect("shared local events directory should create");
+ }
+ let executor =
+ 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 listing_addr = format!("30402:{seller_pubkey}:{listing_key}");
+ let content = json!({
+ "d_tag": listing_key,
+ "status": "active",
+ "farm": {
+ "pubkey": seller_pubkey,
+ "d_tag": farm_key
+ },
+ "product": {
+ "key": listing_key,
+ "title": "Seller decision lettuce",
+ "summary": "Signed listing for seller decision tests"
+ },
+ "availability": {
+ "kind": "window",
+ "amount": {
+ "start": 4_102_444_800u64,
+ "end": 4_102_531_200u64
+ }
+ },
+ "delivery_method": {
+ "kind": "pickup"
+ },
+ "location": {
+ "primary": "North barn pickup"
+ }
+ });
+ store
+ .append_record(&LocalEventRecordInput {
+ record_id: "app:signed_event:listing:seller-order-decision".to_owned(),
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::Published,
+ source_runtime: SourceRuntime::App,
+ created_at_ms: 1_774_000_000_000,
+ inserted_at_ms: 1_774_000_000_001,
+ owner_account_id: Some(account_id.to_owned()),
+ owner_pubkey: Some(seller_pubkey.to_owned()),
+ farm_id: Some(farm_key.to_owned()),
+ listing_addr: Some(listing_addr),
+ local_work_json: None,
+ event_id: Some(listing_event_id.to_owned()),
+ event_kind: Some(30402),
+ event_pubkey: Some(seller_pubkey.to_owned()),
+ event_created_at: Some(1_774_000_000),
+ event_tags_json: Some(json!([
+ ["d", listing_key],
+ ["a", format!("30340:{seller_pubkey}:{farm_key}")],
+ ["key", listing_key],
+ ["title", "Seller decision lettuce"],
+ ["summary", "Signed listing for seller decision tests"],
+ ["radroots:bin", "seller-order-primary-bin", "1", "each"],
+ [
+ "radroots:price",
+ "seller-order-primary-bin",
+ "8",
+ "USD",
+ "1",
+ "each"
+ ],
+ ["inventory", stock_count.to_string()],
+ ["status", "active"],
+ ["radroots:availability_start", "4102444800"],
+ ["expires_at", "4102531200"],
+ ["delivery", "pickup"],
+ ["location", "North barn pickup"]
+ ])),
+ event_content: Some(content.to_string()),
+ event_sig: Some("signature".to_owned()),
+ raw_event_json: Some(json!({
+ "id": listing_event_id,
+ "kind": 30402,
+ "pubkey": seller_pubkey,
+ "content": content.to_string()
+ })),
+ outbox_status: PublishOutboxStatus::Acknowledged,
+ relay_set_fingerprint: Some("relay-set".to_owned()),
+ relay_delivery_json: Some(json!({
+ "state": "acknowledged",
+ "acknowledged_relays": ["wss://relay.example"]
+ })),
+ })
+ .expect("append app signed listing");
+ }
+
+ fn append_signed_order_request_record(
+ paths: &AppDesktopRuntimePaths,
+ trade_order_id: &str,
+ listing_addr: &str,
+ listing_event_id: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ order_quantity: u32,
+ ) {
+ let database_path = paths
+ .shared_local_events_database_path()
+ .expect("shared local events path");
+ if let Some(parent) = database_path.parent() {
+ fs::create_dir_all(parent).expect("shared local events directory should create");
+ }
+ let executor =
+ 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(),
+ buyer_pubkey: buyer_pubkey.to_owned(),
+ seller_pubkey: seller_pubkey.to_owned(),
+ items: vec![RadrootsTradeOrderItem {
+ 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),
+ },
+ };
+ let parts = radroots_sdk::trade::build_order_request_draft(
+ &RadrootsNostrEventPtr {
+ id: listing_event_id.to_owned(),
+ relays: Some("wss://relay.example".to_owned()),
+ },
+ &order,
+ )
+ .expect("order request draft should build")
+ .into_wire_parts();
+ let record_id = format!("app:signed_event:order-request:{trade_order_id}");
+ let event_id = format!("event-{record_id}");
+ store
+ .append_record(&LocalEventRecordInput {
+ record_id,
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::Published,
+ source_runtime: SourceRuntime::Test,
+ created_at_ms: 1_774_000_010_000,
+ inserted_at_ms: 1_774_000_010_001,
+ owner_account_id: None,
+ owner_pubkey: Some(buyer_pubkey.to_owned()),
+ farm_id: None,
+ listing_addr: Some(listing_addr.to_owned()),
+ local_work_json: None,
+ event_id: Some(event_id.clone()),
+ event_kind: Some(i64::from(parts.kind)),
+ event_pubkey: Some(buyer_pubkey.to_owned()),
+ event_created_at: Some(1_774_000_010),
+ event_tags_json: Some(json!(parts.tags)),
+ event_content: Some(parts.content.clone()),
+ event_sig: Some("signature".to_owned()),
+ raw_event_json: Some(json!({
+ "id": event_id,
+ "kind": parts.kind,
+ "pubkey": buyer_pubkey,
+ "content": parts.content
+ })),
+ outbox_status: PublishOutboxStatus::Acknowledged,
+ relay_set_fingerprint: Some("relay-set".to_owned()),
+ relay_delivery_json: Some(json!({
+ "state": "acknowledged",
+ "acknowledged_relays": ["wss://relay.example"]
+ })),
+ })
+ .expect("append signed order request");
+ }
+
+ fn append_unrelated_signed_event_records(paths: &AppDesktopRuntimePaths, count: usize) {
+ let database_path = paths
+ .shared_local_events_database_path()
+ .expect("shared local events path");
+ let executor =
+ 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 pubkey = "2222222222222222222222222222222222222222222222222222222222222222";
+
+ for index in 0..count {
+ let record_id = format!("app:signed_event:unrelated:{index}");
+ let event_id = format!("event-{record_id}");
+ store
+ .append_record(&LocalEventRecordInput {
+ record_id,
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::Published,
+ source_runtime: SourceRuntime::Test,
+ created_at_ms: 1_774_000_100_000 + i64::try_from(index).unwrap_or_default(),
+ inserted_at_ms: 1_774_000_100_001 + i64::try_from(index).unwrap_or_default(),
+ owner_account_id: None,
+ owner_pubkey: Some(pubkey.to_owned()),
+ farm_id: None,
+ listing_addr: None,
+ local_work_json: None,
+ event_id: Some(event_id.clone()),
+ event_kind: Some(1),
+ event_pubkey: Some(pubkey.to_owned()),
+ event_created_at: Some(
+ 1_774_000_100 + i64::try_from(index).unwrap_or_default(),
+ ),
+ event_tags_json: Some(json!([])),
+ event_content: Some("{}".to_owned()),
+ event_sig: Some("signature".to_owned()),
+ raw_event_json: Some(json!({
+ "id": event_id,
+ "kind": 1,
+ "pubkey": pubkey,
+ "content": "{}"
+ })),
+ outbox_status: PublishOutboxStatus::Acknowledged,
+ relay_set_fingerprint: Some("relay-set".to_owned()),
+ relay_delivery_json: Some(json!({
+ "state": "acknowledged",
+ "acknowledged_relays": ["wss://relay.example"]
+ })),
+ })
+ .expect("append unrelated signed event");
+ }
+ }
+
fn deterministic_cli_listing_product_id(
owner_pubkey: Option<&str>,
listing_key: &str,
diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs
@@ -47,9 +47,10 @@ pub use farm_rules::{AppFarmRulesRepository, derive_farm_rules_readiness};
pub use farm_setup::AppFarmSetupRepository;
pub use local_interop::{
AppLocalInteropImportReport, AppLocalInteropRepository, StoredLocalInteropRecord,
+ projected_order_id_from_trade_request,
};
pub use migrations::latest_schema_version;
-pub use orders::AppOrdersRepository;
+pub use orders::{AppOrdersRepository, SellerOrderDecisionExport, SellerOrderDecisionLineExport};
pub use products::AppProductsRepository;
pub use reminders::AppRemindersRepository;
pub use sync::{
@@ -246,6 +247,15 @@ impl AppSqliteStore {
.load_order_detail(farm_id, order_id)
}
+ pub fn load_seller_order_decision_export(
+ &self,
+ farm_id: FarmId,
+ order_id: OrderId,
+ ) -> Result<Option<SellerOrderDecisionExport>, AppSqliteError> {
+ self.orders_repository()
+ .load_seller_order_decision_export(farm_id, order_id)
+ }
+
pub fn load_pack_day(
&self,
farm_id: FarmId,
diff --git a/crates/shared/sqlite/src/local_interop.rs b/crates/shared/sqlite/src/local_interop.rs
@@ -1591,7 +1591,7 @@ fn tags_from_json(value: &Value) -> Option<Vec<Vec<String>>> {
})
}
-fn projected_order_id(order_id: &str, buyer_pubkey: &str) -> OrderId {
+pub fn projected_order_id_from_trade_request(order_id: &str, buyer_pubkey: &str) -> OrderId {
order_id.parse().unwrap_or_else(|_| {
OrderId::from(deterministic_uuid(
"radroots-cli-order",
@@ -1601,6 +1601,10 @@ fn projected_order_id(order_id: &str, buyer_pubkey: &str) -> OrderId {
})
}
+fn projected_order_id(order_id: &str, buyer_pubkey: &str) -> OrderId {
+ projected_order_id_from_trade_request(order_id, buyer_pubkey)
+}
+
fn order_line_product_id(
payload: &RadrootsTradeOrderRequested,
existing_listing: Option<&ExistingListingProjection>,
diff --git a/crates/shared/sqlite/src/orders.rs b/crates/shared/sqlite/src/orders.rs
@@ -7,7 +7,7 @@ use radroots_app_models::{
PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry,
PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow,
PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow,
- PackDayScreenQueryState,
+ PackDayScreenQueryState, ProductId,
};
use rusqlite::{Connection, OptionalExtension, params};
@@ -17,6 +17,23 @@ pub struct AppOrdersRepository<'a> {
connection: &'a Connection,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SellerOrderDecisionExport {
+ pub order_id: OrderId,
+ pub farm_id: FarmId,
+ pub status: OrderStatus,
+ pub lines: Vec<SellerOrderDecisionLineExport>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SellerOrderDecisionLineExport {
+ pub product_id: ProductId,
+ pub listing_bin_id: Option<String>,
+ pub quantity: u32,
+ pub stock_count: Option<u32>,
+ pub reserved_quantity: u32,
+}
+
impl<'a> AppOrdersRepository<'a> {
pub const fn new(connection: &'a Connection) -> Self {
Self { connection }
@@ -118,6 +135,48 @@ impl<'a> AppOrdersRepository<'a> {
.transpose()
}
+ pub fn load_seller_order_decision_export(
+ &self,
+ farm_id: FarmId,
+ order_id: OrderId,
+ ) -> Result<Option<SellerOrderDecisionExport>, AppSqliteError> {
+ let Some((order_id, farm_id, status)) = self
+ .connection
+ .query_row(
+ "select id, farm_id, status
+ from orders
+ where farm_id = ?1 and id = ?2
+ limit 1",
+ params![farm_id.to_string(), order_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ ))
+ },
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load seller order decision export",
+ source,
+ })?
+ else {
+ return Ok(None);
+ };
+ let order_id = parse_typed_id("orders.id", order_id)?;
+ let farm_id = parse_typed_id("orders.farm_id", farm_id)?;
+ let status = parse_order_status("orders.status", status)?;
+ let lines = self.load_seller_order_decision_lines(order_id)?;
+
+ Ok(Some(SellerOrderDecisionExport {
+ order_id,
+ farm_id,
+ status,
+ lines,
+ }))
+ }
+
pub fn load_pack_day(
&self,
farm_id: FarmId,
@@ -322,6 +381,145 @@ impl<'a> AppOrdersRepository<'a> {
})
}
+ fn load_seller_order_decision_lines(
+ &self,
+ order_id: OrderId,
+ ) -> Result<Vec<SellerOrderDecisionLineExport>, AppSqliteError> {
+ let mut statement = self
+ .connection
+ .prepare(
+ "select id, quantity_value, listing_bin_id
+ from order_lines
+ where order_id = ?1
+ order by sort_index asc, id asc",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare seller order decision lines",
+ source,
+ })?;
+ let rows = statement
+ .query_map(params![order_id.to_string()], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option<String>>(2)?,
+ ))
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query seller order decision lines",
+ source,
+ })?;
+ let mut lines = Vec::new();
+
+ for row in rows {
+ let (line_id, quantity, listing_bin_id) =
+ row.map_err(|source| AppSqliteError::Query {
+ operation: "read seller order decision line",
+ source,
+ })?;
+ let product_id = parse_order_line_product_id(line_id.as_str(), order_id)?;
+ let quantity =
+ u32::try_from(quantity).map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "seller order decision quantity must be non-negative",
+ })?;
+ if quantity == 0 {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision quantity must be positive",
+ });
+ }
+ lines.push(SellerOrderDecisionLineExport {
+ product_id,
+ listing_bin_id: empty_string_to_none(listing_bin_id),
+ quantity,
+ stock_count: self.load_product_stock_count(product_id)?,
+ reserved_quantity: self.load_reserved_product_quantity(product_id, order_id)?,
+ });
+ }
+
+ Ok(lines)
+ }
+
+ fn load_product_stock_count(
+ &self,
+ product_id: ProductId,
+ ) -> Result<Option<u32>, AppSqliteError> {
+ let stock_count = self
+ .connection
+ .query_row(
+ "select stock_count from products where id = ?1 limit 1",
+ params![product_id.to_string()],
+ |row| row.get::<_, Option<i64>>(0),
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load seller order decision product stock",
+ source,
+ })?
+ .flatten();
+
+ stock_count
+ .map(|value| {
+ u32::try_from(value).map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "seller order decision product stock must be non-negative",
+ })
+ })
+ .transpose()
+ }
+
+ fn load_reserved_product_quantity(
+ &self,
+ product_id: ProductId,
+ excluding_order_id: OrderId,
+ ) -> Result<u32, AppSqliteError> {
+ let mut statement = self
+ .connection
+ .prepare(
+ "select ol.id, ol.quantity_value
+ from order_lines ol
+ inner join orders o on o.id = ol.order_id
+ where o.status in ('scheduled', 'packed')
+ and o.id <> ?1",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare seller order decision reservations",
+ source,
+ })?;
+ let rows = statement
+ .query_map(params![excluding_order_id.to_string()], |row| {
+ Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query seller order decision reservations",
+ source,
+ })?;
+ let mut reserved_quantity = 0_u32;
+
+ for row in rows {
+ let (line_id, quantity) = row.map_err(|source| AppSqliteError::Query {
+ operation: "read seller order decision reservation",
+ source,
+ })?;
+ let Some(reserved_product_id) = parse_order_line_product_id_lossy(line_id.as_str())
+ else {
+ continue;
+ };
+ if reserved_product_id != product_id {
+ continue;
+ }
+ let quantity =
+ u32::try_from(quantity).map_err(|_| AppSqliteError::InvalidProjection {
+ reason: "seller order decision reserved quantity must be non-negative",
+ })?;
+ reserved_quantity = reserved_quantity.checked_add(quantity).ok_or(
+ AppSqliteError::InvalidProjection {
+ reason: "seller order decision reserved quantity overflowed",
+ },
+ )?;
+ }
+
+ Ok(reserved_quantity)
+ }
+
fn load_fulfillment_window_by_id(
&self,
farm_id: FarmId,
@@ -963,6 +1161,26 @@ where
value.map(|value| parse_typed_id(field, value)).transpose()
}
+fn parse_order_line_product_id(
+ line_id: &str,
+ order_id: OrderId,
+) -> Result<ProductId, AppSqliteError> {
+ let prefix = format!("{order_id}:");
+ let Some(product_id) = line_id.strip_prefix(prefix.as_str()) else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "seller order decision line id must include order id prefix",
+ });
+ };
+
+ parse_typed_id("order_lines.product_id", product_id.to_owned())
+}
+
+fn parse_order_line_product_id_lossy(line_id: &str) -> Option<ProductId> {
+ line_id
+ .rsplit_once(':')
+ .and_then(|(_, product_id)| product_id.parse().ok())
+}
+
fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, AppSqliteError> {
match value.as_str() {
"needs_action" => Ok(OrderStatus::NeedsAction),
diff --git a/crates/shared/sync/src/lib.rs b/crates/shared/sync/src/lib.rs
@@ -3,7 +3,8 @@
mod publish;
pub use publish::{
- AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload,
+ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionInventoryCommitment,
+ AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderRequestItemPayload,
AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload,
AppPublishPayloadJsonError, AppPublishValidationFailure, AppPublishValidationFailureSet,
AppPublishWorkKind,
diff --git a/crates/shared/sync/src/publish.rs b/crates/shared/sync/src/publish.rs
@@ -13,6 +13,7 @@ pub enum AppPublishWorkKind {
FarmProfile,
Listing,
OrderRequest,
+ OrderDecision,
}
impl AppPublishWorkKind {
@@ -21,6 +22,7 @@ impl AppPublishWorkKind {
Self::FarmProfile => "farm_profile",
Self::Listing => "listing",
Self::OrderRequest => "order_request",
+ Self::OrderDecision => "order_decision",
}
}
@@ -29,6 +31,7 @@ impl AppPublishWorkKind {
Self::FarmProfile => "farm.publish_draft_with_identity",
Self::Listing => "listing.publish_draft_with_identity",
Self::OrderRequest => "trade.publish_order_request_with_identity",
+ Self::OrderDecision => "trade.publish_order_decision_with_identity",
}
}
}
@@ -121,11 +124,52 @@ pub struct AppOrderRequestPublishPayload {
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct AppOrderDecisionInventoryCommitment {
+ pub bin_id: String,
+ pub bin_count: u32,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case", tag = "decision")]
+pub enum AppOrderDecisionPayload {
+ Accepted {
+ inventory_commitments: Vec<AppOrderDecisionInventoryCommitment>,
+ },
+ Declined {
+ reason: String,
+ },
+}
+
+impl AppOrderDecisionPayload {
+ pub const fn storage_key(&self) -> &'static str {
+ match self {
+ Self::Accepted { .. } => "accepted",
+ Self::Declined { .. } => "declined",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct AppOrderDecisionPublishPayload {
+ pub context: AppPublishContext,
+ pub app_order_id: OrderId,
+ pub farm_id: FarmId,
+ pub trade_order_id: String,
+ pub request_event_id: String,
+ pub listing_event_id: Option<String>,
+ pub listing_addr: String,
+ pub buyer_pubkey: String,
+ pub seller_pubkey: String,
+ pub decision: AppOrderDecisionPayload,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "publish_kind", content = "payload", rename_all = "snake_case")]
pub enum AppPublishPayload {
FarmProfile(AppFarmProfilePublishPayload),
Listing(AppListingPublishPayload),
OrderRequest(AppOrderRequestPublishPayload),
+ OrderDecision(AppOrderDecisionPublishPayload),
}
impl AppPublishPayload {
@@ -134,6 +178,7 @@ impl AppPublishPayload {
Self::FarmProfile(_) => AppPublishWorkKind::FarmProfile,
Self::Listing(_) => AppPublishWorkKind::Listing,
Self::OrderRequest(_) => AppPublishWorkKind::OrderRequest,
+ Self::OrderDecision(_) => AppPublishWorkKind::OrderDecision,
}
}
@@ -150,6 +195,7 @@ impl AppPublishPayload {
Self::FarmProfile(payload) => SyncAggregateRef::Farm(payload.farm_id),
Self::Listing(payload) => SyncAggregateRef::Product(payload.product_id),
Self::OrderRequest(payload) => SyncAggregateRef::Order(payload.order_id),
+ Self::OrderDecision(payload) => SyncAggregateRef::Order(payload.app_order_id),
}
}
@@ -278,6 +324,43 @@ impl AppPublishPayload {
failures.push(AppPublishValidationFailure::MissingOrderTotal);
}
}
+ Self::OrderDecision(payload) => {
+ payload.context.validation_failures(&mut failures);
+ if payload.trade_order_id.trim().is_empty() {
+ failures.push(AppPublishValidationFailure::MissingOrderTradeOrderId);
+ }
+ if payload.request_event_id.trim().is_empty() {
+ failures.push(AppPublishValidationFailure::MissingOrderRequestEventId);
+ }
+ if payload.listing_addr.trim().is_empty() {
+ failures.push(AppPublishValidationFailure::MissingOrderListingAddress);
+ }
+ if payload.buyer_pubkey.trim().is_empty() {
+ failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey);
+ }
+ if payload.seller_pubkey.trim().is_empty() {
+ failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey);
+ }
+ match &payload.decision {
+ AppOrderDecisionPayload::Accepted {
+ inventory_commitments,
+ } => {
+ if inventory_commitments.is_empty()
+ || inventory_commitments.iter().any(|commitment| {
+ commitment.bin_id.trim().is_empty() || commitment.bin_count == 0
+ })
+ {
+ failures
+ .push(AppPublishValidationFailure::MissingOrderDecisionInventory);
+ }
+ }
+ AppOrderDecisionPayload::Declined { reason } => {
+ if reason.trim().is_empty() {
+ failures.push(AppPublishValidationFailure::MissingOrderDeclineReason);
+ }
+ }
+ }
+ }
}
failures
@@ -325,6 +408,10 @@ pub enum AppPublishValidationFailure {
MissingOrderItems,
MissingOrderCurrency,
MissingOrderTotal,
+ MissingOrderTradeOrderId,
+ MissingOrderRequestEventId,
+ MissingOrderDecisionInventory,
+ MissingOrderDeclineReason,
}
impl AppPublishValidationFailure {
@@ -353,6 +440,10 @@ impl AppPublishValidationFailure {
Self::MissingOrderItems => "missing_order_items",
Self::MissingOrderCurrency => "missing_order_currency",
Self::MissingOrderTotal => "missing_order_total",
+ Self::MissingOrderTradeOrderId => "missing_order_trade_order_id",
+ Self::MissingOrderRequestEventId => "missing_order_request_event_id",
+ Self::MissingOrderDecisionInventory => "missing_order_decision_inventory",
+ Self::MissingOrderDeclineReason => "missing_order_decline_reason",
}
}
}
@@ -404,9 +495,9 @@ impl PendingSyncOperation {
#[cfg(test)]
mod tests {
use super::{
- AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload,
- AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload,
- AppPublishValidationFailure,
+ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionPayload,
+ AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload,
+ AppPublishContext, AppPublishPayload, AppPublishValidationFailure,
};
use crate::{
PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind,
@@ -546,6 +637,49 @@ mod tests {
}
#[test]
+ fn order_decision_publish_payload_reports_stable_validation_reason_codes() {
+ let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload {
+ context: AppPublishContext::new("", ""),
+ app_order_id: OrderId::new(),
+ farm_id: FarmId::new(),
+ trade_order_id: " ".to_owned(),
+ request_event_id: String::new(),
+ listing_event_id: None,
+ listing_addr: String::new(),
+ buyer_pubkey: String::new(),
+ seller_pubkey: String::new(),
+ decision: AppOrderDecisionPayload::Declined {
+ reason: " ".to_owned(),
+ },
+ });
+
+ assert_eq!(payload.work_kind().storage_key(), "order_decision");
+ assert_eq!(
+ payload.work_kind().sdk_operation(),
+ "trade.publish_order_decision_with_identity"
+ );
+ let reason_codes: Vec<&str> = payload
+ .validation_failures()
+ .into_iter()
+ .map(AppPublishValidationFailure::storage_key)
+ .collect();
+
+ assert_eq!(
+ reason_codes,
+ vec![
+ "missing_account_id",
+ "missing_source",
+ "missing_order_trade_order_id",
+ "missing_order_request_event_id",
+ "missing_order_listing_address",
+ "missing_order_buyer_pubkey",
+ "missing_order_seller_pubkey",
+ "missing_order_decline_reason",
+ ]
+ );
+ }
+
+ #[test]
fn existing_raw_payload_outbox_work_remains_local_save_compatible() {
let pending_operation = PendingSyncOperation {
operation_key: "product:greens:upsert".to_owned(),