commit ea3c806201eb2fd58721c00df2d3fb412839a4e0
parent 153d1ff6e81c9a928cbeab78d411fe2d9522bad1
Author: triesap <tyson@radroots.org>
Date: Sun, 24 May 2026 10:28:57 +0000
app: append buyer order local work
- export app buyer orders from SQLite with listing identity and pricing references
- append deterministic no-payment buyer order request records into shared local-events
- route order placement failures through typed buyer notice copy
- prove supported order export, idempotent reappend, and append failure behavior
Diffstat:
8 files changed, 948 insertions(+), 9 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -34,8 +34,8 @@ use radroots_app_remote_signer::{
};
use radroots_app_sqlite::{
APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSqliteError, AppSqliteStore,
- BuyerRepeatDemandApplyOutcome, DatabaseTarget, StoredPendingSyncOperation, StoredSyncConflict,
- derive_farm_rules_readiness,
+ BuyerOrderLocalEventExport, BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome,
+ DatabaseTarget, StoredPendingSyncOperation, StoredSyncConflict, derive_farm_rules_readiness,
};
use radroots_app_state::{
APP_STATE_FILE_NAME, AppShellProjection, AppStateCommand, AppStatePersistenceRepository,
@@ -52,8 +52,11 @@ use radroots_app_sync::{
SyncOperationKind, SyncTrigger,
};
use radroots_local_events::{
- LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus,
- PublishOutboxStatus, SourceRuntime,
+ BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT,
+ BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, BUYER_ORDER_REQUEST_DOCUMENT_KIND,
+ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore,
+ LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime,
+ buyer_order_request_local_work_record_id, validate_buyer_order_request_local_work_payload,
};
use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager;
use radroots_sql_core::SqliteExecutor;
@@ -1407,6 +1410,14 @@ impl DesktopAppRuntimeState {
reason: "buyer order write did not surface in buyer order detail",
});
};
+ let Some(order_export) =
+ sqlite_store.load_buyer_order_local_event_export(&buyer_context, order_id)?
+ else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer order write did not surface in buyer order local event export",
+ });
+ };
+ self.append_app_buyer_order_request_local_work_record(&buyer_context, &order_export)?;
let personal_changed = self.mutate_personal_projection(|projection| {
let mut changed = false;
@@ -3538,6 +3549,69 @@ impl DesktopAppRuntimeState {
Ok(true)
}
+ fn append_app_buyer_order_request_local_work_record(
+ &self,
+ buyer_context: &BuyerContext,
+ order: &BuyerOrderLocalEventExport,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else {
+ return Ok(false);
+ };
+ let timestamp = current_runtime_time_ms()?;
+ let record_id = buyer_order_request_local_work_record_id(
+ order.order_id.to_string().as_str(),
+ )
+ .map_err(|source| AppSqliteError::LocalEvents {
+ operation: "build app buyer order request record id",
+ source,
+ })?;
+ let buyer_account = self.selected_buyer_account(buyer_context);
+ let owner_account_id = buyer_account.map(|account| account.account.account_id.clone());
+ let buyer_pubkey =
+ buyer_account.and_then(|account| self.local_events_owner_pubkey(account));
+ let export = AppBuyerOrderRequestExport::from_order(order, buyer_pubkey.as_deref())?;
+ let payload = buyer_order_request_local_work_payload(
+ order,
+ buyer_context,
+ &record_id,
+ &export,
+ timestamp,
+ );
+ validate_buyer_order_request_local_work_payload(&payload).map_err(|source| {
+ AppSqliteError::LocalEvents {
+ operation: "validate app buyer order request local work payload",
+ source,
+ }
+ })?;
+ let input = LocalEventRecordInput {
+ record_id,
+ family: LocalRecordFamily::LocalWork,
+ status: LocalRecordStatus::LocalSaved,
+ source_runtime: SourceRuntime::App,
+ created_at_ms: timestamp,
+ inserted_at_ms: timestamp,
+ owner_account_id,
+ owner_pubkey: buyer_pubkey,
+ farm_id: export.farm_key.clone(),
+ listing_addr: export.listing_addr.clone(),
+ local_work_json: Some(payload),
+ event_id: None,
+ event_kind: None,
+ event_pubkey: None,
+ event_created_at: None,
+ event_tags_json: None,
+ event_content: None,
+ event_sig: None,
+ raw_event_json: None,
+ outbox_status: PublishOutboxStatus::None,
+ relay_set_fingerprint: None,
+ relay_delivery_json: None,
+ };
+
+ self.append_app_local_work_record(shared_accounts_paths, &input)?;
+ Ok(true)
+ }
+
fn append_app_local_work_record(
&self,
shared_accounts_paths: &AppSharedAccountsPaths,
@@ -3594,6 +3668,20 @@ impl DesktopAppRuntimeState {
.filter(|pubkey| is_hex_64(pubkey))
}
+ fn selected_buyer_account(
+ &self,
+ buyer_context: &BuyerContext,
+ ) -> Option<&radroots_app_models::SelectedAccountProjection> {
+ let BuyerContext::Account(account_id) = buyer_context else {
+ return None;
+ };
+ self.state_store
+ .identity_projection()
+ .selected_account
+ .as_ref()
+ .filter(|account| account.account.account_id == *account_id)
+ }
+
fn refresh_selected_account_context_after_local_events(
&mut self,
) -> Result<bool, AppSqliteError> {
@@ -3963,6 +4051,15 @@ fn decimal_from_minor_units(value: u32) -> String {
format!("{}.{:02}", value / 100, value % 100)
}
+fn normalize_currency_code(value: &str) -> String {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ "USD".to_owned()
+ } else {
+ trimmed.to_ascii_uppercase()
+ }
+}
+
fn is_hex_64(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
@@ -3979,6 +4076,294 @@ fn local_work_exportability(owner_pubkey: Option<&str>) -> serde_json::Value {
}
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct AppBuyerOrderRequestExport {
+ buyer_pubkey: Option<String>,
+ seller_pubkey: Option<String>,
+ listing_addr: Option<String>,
+ listing_event_id: Option<String>,
+ farm_key: Option<String>,
+ order_items: Vec<serde_json::Value>,
+ line_refs: Vec<serde_json::Value>,
+ economics: Option<serde_json::Value>,
+ support_issues: Vec<&'static str>,
+}
+
+impl AppBuyerOrderRequestExport {
+ fn from_order(
+ order: &BuyerOrderLocalEventExport,
+ buyer_pubkey: Option<&str>,
+ ) -> Result<Self, AppSqliteError> {
+ let mut support_issues = Vec::new();
+ if buyer_pubkey.is_none() {
+ support_issues.push("buyer_pubkey_required");
+ }
+ if order.lines.is_empty() {
+ support_issues.push("order_lines_required");
+ }
+ let listing_addr =
+ shared_optional_line_value(&order.lines, |line| line.listing_addr.as_deref());
+ let listing_event_id =
+ shared_optional_line_value(&order.lines, |line| line.listing_event_id.as_deref());
+ let seller_pubkey =
+ shared_optional_line_value(&order.lines, |line| line.seller_pubkey.as_deref());
+ let farm_key = shared_optional_line_value(&order.lines, |line| line.farm_key.as_deref())
+ .or_else(|| Some(d_tag_from_uuid(order.farm_id.as_uuid())));
+
+ if listing_addr.is_none() {
+ support_issues.push("single_listing_addr_required");
+ }
+ if listing_event_id.is_none() {
+ support_issues.push("listing_event_id_required");
+ }
+ if seller_pubkey.is_none() {
+ support_issues.push("seller_pubkey_required");
+ }
+
+ let mut order_items = Vec::with_capacity(order.lines.len());
+ let mut line_refs = Vec::with_capacity(order.lines.len());
+ for line in &order.lines {
+ order_items.push(json!({
+ "bin_id": "bin-1",
+ "bin_count": line.quantity,
+ }));
+ line_refs.push(json!({
+ "product_id": line.product_id.to_string(),
+ "title": line.title,
+ "quantity": {
+ "count": line.quantity,
+ "display": line.quantity_display,
+ "unit_label": line.quantity_unit_label,
+ },
+ "listing_addr": line.listing_addr,
+ "listing_event_id": line.listing_event_id,
+ "seller_pubkey": line.seller_pubkey,
+ "farm_key": line.farm_key,
+ }));
+ }
+
+ let economics = order_economics_json(order, &mut support_issues)?;
+
+ Ok(Self {
+ buyer_pubkey: buyer_pubkey.map(str::to_owned),
+ seller_pubkey,
+ listing_addr,
+ listing_event_id,
+ farm_key,
+ order_items,
+ line_refs,
+ economics,
+ support_issues,
+ })
+ }
+
+ fn is_supported(&self) -> bool {
+ self.support_issues.is_empty()
+ }
+}
+
+fn buyer_order_request_local_work_payload(
+ order: &BuyerOrderLocalEventExport,
+ buyer_context: &BuyerContext,
+ record_id: &str,
+ export: &AppBuyerOrderRequestExport,
+ timestamp: i64,
+) -> serde_json::Value {
+ let buyer_account_id = match buyer_context {
+ BuyerContext::Account(account_id) => account_id.as_str(),
+ BuyerContext::Guest => "",
+ };
+ let buyer_actor_source = if export.buyer_pubkey.is_some() {
+ BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT
+ } else {
+ BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP
+ };
+
+ json!({
+ "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND,
+ "scope": "app",
+ "exportability": local_work_exportability(export.buyer_pubkey.as_deref()),
+ "support_status": {
+ "state": if export.is_supported() { "supported" } else { "unsupported" },
+ "issues": export.support_issues.clone(),
+ },
+ "currentness": {
+ "current": true,
+ "source": "app_sqlite_order",
+ "record_id": record_id,
+ "order_id": order.order_id.to_string(),
+ "order_updated_at": order.updated_at,
+ "created_at_ms": timestamp,
+ },
+ "no_payment": {
+ "payment_required": false,
+ "settlement_deferred": true,
+ "payment_state": "not_applicable",
+ },
+ "document": {
+ "version": 1,
+ "kind": BUYER_ORDER_REQUEST_DOCUMENT_KIND,
+ "order": {
+ "order_id": order.order_id.to_string(),
+ "listing_addr": export.listing_addr.as_deref().unwrap_or_default(),
+ "listing_event_id": export.listing_event_id.as_deref().unwrap_or_default(),
+ "buyer_pubkey": export.buyer_pubkey.as_deref().unwrap_or_default(),
+ "seller_pubkey": export.seller_pubkey.as_deref().unwrap_or_default(),
+ "items": export.order_items.clone(),
+ "economics": export.economics.clone(),
+ },
+ "buyer_actor": {
+ "account_id": buyer_account_id,
+ "pubkey": export.buyer_pubkey.as_deref().unwrap_or_default(),
+ "source": buyer_actor_source,
+ },
+ "listing_lookup": export.listing_addr.clone(),
+ },
+ "app_order": {
+ "order_id": order.order_id.to_string(),
+ "order_number": order.order_number,
+ "farm_id": order.farm_id.to_string(),
+ "farm_display_name": order.farm_display_name,
+ "farm_key": export.farm_key.clone(),
+ "status": order.status,
+ "buyer_context_key": order.buyer_context_key,
+ "buyer_name": order.buyer_name,
+ "buyer_email": order.buyer_email,
+ "buyer_phone": order.buyer_phone,
+ "buyer_order_note": order.buyer_order_note,
+ "fulfillment": {
+ "window_id": order.fulfillment_window_id.map(|id| id.to_string()),
+ "label": order.fulfillment_window_label,
+ "starts_at": order.fulfillment_starts_at,
+ "ends_at": order.fulfillment_ends_at,
+ },
+ "lines": export.line_refs.clone(),
+ },
+ })
+}
+
+fn order_economics_json(
+ order: &BuyerOrderLocalEventExport,
+ support_issues: &mut Vec<&'static str>,
+) -> Result<Option<serde_json::Value>, AppSqliteError> {
+ let mut economics_items = Vec::with_capacity(order.lines.len());
+ let mut subtotal_minor_units = 0_u32;
+ let mut currency = None::<String>;
+
+ for line in &order.lines {
+ let Some(quantity_unit) = canonical_quantity_unit(line.quantity_unit_label.as_str()) else {
+ support_issues.push("canonical_quantity_unit_required");
+ continue;
+ };
+ let Some(unit_price_minor_units) = line.unit_price_minor_units else {
+ support_issues.push("unit_price_required");
+ continue;
+ };
+ if unit_price_minor_units == 0 {
+ support_issues.push("positive_unit_price_required");
+ continue;
+ }
+ let line_currency = normalize_currency_code(line.price_currency.as_str());
+ if line_currency.len() != 3 || !line_currency.bytes().all(|byte| byte.is_ascii_uppercase())
+ {
+ support_issues.push("canonical_currency_required");
+ continue;
+ }
+ if let Some(existing_currency) = currency.as_deref() {
+ if existing_currency != line_currency {
+ support_issues.push("single_currency_required");
+ continue;
+ }
+ } else {
+ currency = Some(line_currency.clone());
+ }
+ let line_subtotal_minor_units = unit_price_minor_units.checked_mul(line.quantity).ok_or(
+ AppSqliteError::InvalidProjection {
+ reason: "buyer order local event line subtotal overflowed",
+ },
+ )?;
+ subtotal_minor_units = subtotal_minor_units
+ .checked_add(line_subtotal_minor_units)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer order local event subtotal overflowed",
+ })?;
+ economics_items.push(json!({
+ "bin_id": "bin-1",
+ "bin_count": line.quantity,
+ "quantity_amount": line.quantity.to_string(),
+ "quantity_unit": quantity_unit,
+ "unit_price_amount": decimal_from_minor_units(unit_price_minor_units),
+ "unit_price_currency": line_currency,
+ "line_subtotal": {
+ "amount": decimal_from_minor_units(line_subtotal_minor_units),
+ "currency": line_currency,
+ },
+ }));
+ }
+
+ if economics_items.len() != order.lines.len() || economics_items.is_empty() {
+ return Ok(None);
+ }
+
+ let currency = currency.unwrap_or_else(|| "USD".to_owned());
+ let subtotal = json!({
+ "amount": decimal_from_minor_units(subtotal_minor_units),
+ "currency": currency,
+ });
+ Ok(Some(json!({
+ "quote_id": format!("app-order:{}", order.order_id),
+ "quote_version": 1,
+ "pricing_basis": "listing_event",
+ "currency": currency,
+ "items": economics_items,
+ "discounts": [],
+ "adjustments": [],
+ "subtotal": subtotal,
+ "discount_total": {
+ "amount": "0",
+ "currency": currency,
+ },
+ "adjustment_total": {
+ "amount": "0",
+ "currency": currency,
+ },
+ "total": subtotal,
+ })))
+}
+
+fn shared_optional_line_value(
+ lines: &[BuyerOrderLocalEventLine],
+ value: impl Fn(&BuyerOrderLocalEventLine) -> Option<&str>,
+) -> Option<String> {
+ let mut resolved = None::<String>;
+ for line in lines {
+ let Some(next) = value(line).map(str::trim).filter(|next| !next.is_empty()) else {
+ return None;
+ };
+ if let Some(existing) = resolved.as_deref() {
+ if existing != next {
+ return None;
+ }
+ } else {
+ resolved = Some(next.to_owned());
+ }
+ }
+ resolved
+}
+
+fn canonical_quantity_unit(unit_label: &str) -> Option<&'static str> {
+ match unit_label.trim().to_ascii_lowercase().as_str() {
+ "each" | "ea" | "count" => Some("each"),
+ "kg" | "kilogram" | "kilograms" => Some("kg"),
+ "g" | "gram" | "grams" => Some("g"),
+ "oz" | "ounce" | "ounces" => Some("oz"),
+ "lb" | "pound" | "pounds" => Some("lb"),
+ "l" | "liter" | "litre" | "liters" | "litres" => Some("l"),
+ "ml" | "milliliter" | "millilitre" | "milliliters" | "millilitres" => Some("ml"),
+ _ => None,
+ }
+}
+
fn load_selected_account_context(
sqlite_store: &AppSqliteStore,
identity_projection: &AppIdentityProjection,
@@ -5307,8 +5692,8 @@ mod tests {
};
use radroots_identity::RadrootsIdentity;
use radroots_local_events::{
- LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily,
- LocalRecordStatus, PublishOutboxStatus, SourceRuntime,
+ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalEventRecordInput,
+ LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime,
};
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore,
@@ -7996,6 +8381,213 @@ mod tests {
}
#[test]
+ fn runtime_places_supported_buyer_order_into_shared_local_events() {
+ let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event");
+ assert!(
+ runtime
+ .generate_local_account(Some("Buyer".to_owned()))
+ .expect("account should generate")
+ );
+ assert!(
+ runtime
+ .select_active_surface(ActiveSurface::Personal)
+ .expect("surface should switch into marketplace")
+ );
+ let buyer_account_id = runtime
+ .summary()
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .expect("selected account")
+ .account
+ .account_id
+ .clone();
+ let listing_key = "DDDDDDDDDDDDDDDDDDDDDD";
+ append_cli_signed_buyer_listing_record_with(
+ &paths,
+ "buyer-order-supported-listing",
+ listing_key,
+ "Buyer Visible Eggs",
+ 1100,
+ );
+ let product_id =
+ deterministic_cli_listing_product_id(Some("buyer-visible-seller-pubkey"), listing_key);
+
+ assert!(
+ runtime
+ .open_personal_product_detail(PersonalSection::Browse, product_id)
+ .expect("buyer detail should import before lookup")
+ );
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("buyer product should add to cart")
+ );
+ assert!(
+ runtime
+ .save_personal_checkout_draft(BuyerCheckoutDraft {
+ name: "Casey Buyer".to_owned(),
+ email: "casey@example.com".to_owned(),
+ phone: "555-0101".to_owned(),
+ order_note: "Leave by the cooler".to_owned(),
+ })
+ .expect("buyer checkout draft should save")
+ );
+ assert!(
+ runtime
+ .place_personal_order()
+ .expect("buyer order should place")
+ );
+ let order_id = runtime.summary().personal_projection.orders.list.rows[0].order_id;
+
+ {
+ let state = runtime.lock_state_mut();
+ let buyer_context = state.state_store.identity_projection().buyer_context();
+ let order_export = state
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .load_buyer_order_local_event_export(&buyer_context, order_id)
+ .expect("order export should load")
+ .expect("order export should exist");
+ assert!(
+ state
+ .append_app_buyer_order_request_local_work_record(&buyer_context, &order_export)
+ .expect("order local event reappend should be idempotent")
+ );
+ }
+
+ let records = shared_local_event_records(&paths);
+ let order_records = records
+ .iter()
+ .filter(|record| {
+ record.source_runtime == SourceRuntime::App
+ && record
+ .local_work_json
+ .as_ref()
+ .and_then(|payload| payload["record_kind"].as_str())
+ == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND)
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(order_records.len(), 1);
+ let order_record = order_records[0];
+ assert_eq!(order_record.family, LocalRecordFamily::LocalWork);
+ assert_eq!(order_record.status, LocalRecordStatus::LocalSaved);
+ assert_eq!(order_record.outbox_status, PublishOutboxStatus::None);
+ assert_eq!(
+ order_record.record_id,
+ format!("app:local_work:order_request:{order_id}")
+ );
+ assert_eq!(
+ order_record.owner_account_id.as_deref(),
+ Some(buyer_account_id.as_str())
+ );
+ assert!(order_record.owner_pubkey.as_deref().is_some_and(is_hex_64));
+ assert_eq!(
+ order_record.listing_addr.as_deref(),
+ Some(format!("30402:buyer-visible-seller-pubkey:{listing_key}").as_str())
+ );
+ let payload = order_record
+ .local_work_json
+ .as_ref()
+ .expect("order local work payload");
+ assert_eq!(payload["support_status"]["state"], "supported");
+ assert_eq!(payload["no_payment"]["payment_required"], false);
+ assert_eq!(payload["no_payment"]["settlement_deferred"], true);
+ assert_eq!(payload["currentness"]["current"], true);
+ assert_eq!(payload["document"]["kind"], "order_draft_v1");
+ assert_eq!(
+ payload["document"]["order"]["order_id"],
+ order_id.to_string()
+ );
+ assert_eq!(
+ payload["document"]["order"]["listing_event_id"],
+ "event-cli:signed_event:buyer-order-supported-listing"
+ );
+ assert_eq!(
+ payload["document"]["order"]["seller_pubkey"],
+ "buyer-visible-seller-pubkey"
+ );
+ assert_eq!(payload["document"]["order"]["items"][0]["bin_id"], "bin-1");
+ assert_eq!(payload["document"]["order"]["items"][0]["bin_count"], 1);
+ assert_eq!(
+ payload["document"]["order"]["economics"]["pricing_basis"],
+ "listing_event"
+ );
+ assert_eq!(
+ payload["document"]["order"]["economics"]["total"]["amount"],
+ "8.00"
+ );
+ assert_eq!(
+ payload["app_order"]["buyer_order_note"],
+ "Leave by the cooler"
+ );
+
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
+ fn runtime_buyer_order_shared_append_failure_blocks_visible_completion() {
+ let (runtime, paths) = bootstrapped_runtime("buyer_order_append_failure");
+ assert!(
+ runtime
+ .generate_local_account(Some("Buyer".to_owned()))
+ .expect("account should generate")
+ );
+ assert!(
+ runtime
+ .select_active_surface(ActiveSurface::Personal)
+ .expect("surface should switch into marketplace")
+ );
+ let listing_key = "DDDDDDDDDDDDDDDDDDDDDD";
+ append_cli_signed_buyer_listing_record_with(
+ &paths,
+ "buyer-order-append-failure-listing",
+ listing_key,
+ "Buyer Visible Eggs",
+ 1100,
+ );
+ let product_id =
+ deterministic_cli_listing_product_id(Some("buyer-visible-seller-pubkey"), listing_key);
+ assert!(
+ runtime
+ .open_personal_product_detail(PersonalSection::Browse, product_id)
+ .expect("buyer detail should import before lookup")
+ );
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("buyer product should add to cart")
+ );
+ assert!(
+ runtime
+ .save_personal_checkout_draft(BuyerCheckoutDraft {
+ name: "Casey Buyer".to_owned(),
+ email: "casey@example.com".to_owned(),
+ phone: String::new(),
+ order_note: String::new(),
+ })
+ .expect("buyer checkout draft should save")
+ );
+ block_shared_local_events_database(&paths);
+
+ let error = runtime
+ .place_personal_order()
+ .expect_err("blocked local events should fail order completion");
+
+ assert!(matches!(error, AppSqliteError::LocalEventsSql { .. }));
+ let summary = runtime.summary();
+ assert_ne!(
+ summary.shell_projection.selected_section,
+ ShellSection::Personal(PersonalSection::Orders)
+ );
+ assert!(summary.personal_projection.orders.list.rows.is_empty());
+ assert!(summary.personal_projection.orders.detail.is_none());
+
+ cleanup_bootstrapped_runtime_paths(&paths);
+ }
+
+ #[test]
fn runtime_opens_buyer_order_detail_from_personal_orders() {
let runtime = memory_runtime();
let (account_id, farm_id) = provision_ready_farmer_account(&runtime);
@@ -11451,6 +12043,22 @@ mod tests {
.expect("shared local records should list")
}
+ fn block_shared_local_events_database(paths: &AppDesktopRuntimePaths) {
+ 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");
+ }
+ if database_path.is_file() {
+ fs::remove_file(&database_path).expect("shared local events file should remove");
+ } else if database_path.is_dir() {
+ fs::remove_dir_all(&database_path)
+ .expect("shared local events directory should remove");
+ }
+ fs::create_dir(&database_path).expect("blocking directory should create");
+ }
+
fn fixture_pending_session() -> RadrootsAppRemoteSignerPendingSession {
let signer_identity = RadrootsIdentity::from_secret_key_str(
"1111111111111111111111111111111111111111111111111111111111111111",
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -380,6 +380,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::PersonalSearchPlaceholderBody",
"AppTextKey::PersonalMarketplaceRefreshFailedNotice",
"AppTextKey::PersonalDetailOpenFailedNotice",
+ "AppTextKey::PersonalOrderPlaceFailedNotice",
"AppTextKey::PersonalCartPlaceholderBody",
"AppTextKey::PersonalOrdersSurfaceBody",
"AppTextKey::PersonalOrdersEmptyTitle",
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -303,6 +303,7 @@ enum HomeAutoFocusTarget {
enum BuyerWorkspaceNotice {
MarketplaceRefreshFailed,
DetailOpenFailed,
+ OrderPlaceFailed,
}
impl BuyerWorkspaceNotice {
@@ -310,6 +311,7 @@ impl BuyerWorkspaceNotice {
match self {
Self::MarketplaceRefreshFailed => AppTextKey::PersonalMarketplaceRefreshFailedNotice,
Self::DetailOpenFailed => AppTextKey::PersonalDetailOpenFailedNotice,
+ Self::OrderPlaceFailed => AppTextKey::PersonalOrderPlaceFailedNotice,
}
}
@@ -1542,12 +1544,19 @@ impl HomeView {
}
fn place_personal_order(&mut self, cx: &mut Context<Self>) {
+ if self.place_personal_order_update() {
+ cx.notify();
+ }
+ }
+
+ fn place_personal_order_update(&mut self) -> bool {
match self.runtime.place_personal_order() {
Ok(true) => {
self.buyer_checkout_form = None;
- cx.notify();
+ let _ = self.clear_buyer_workspace_notice();
+ true
}
- Ok(false) => {}
+ Ok(false) => false,
Err(runtime_error) => {
error!(
target: "buyer",
@@ -1555,6 +1564,7 @@ impl HomeView {
error = %runtime_error,
"failed to place buyer order"
);
+ self.set_buyer_workspace_notice(BuyerWorkspaceNotice::OrderPlaceFailed)
}
}
}
@@ -13046,6 +13056,11 @@ mod tests {
Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str())
);
assert!(!view.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed));
+ assert!(view.set_buyer_workspace_notice(BuyerWorkspaceNotice::OrderPlaceFailed));
+ assert_eq!(
+ view.buyer_workspace_notice.as_deref(),
+ Some(app_text(AppTextKey::PersonalOrderPlaceFailedNotice).as_str())
+ );
assert!(view.clear_buyer_workspace_notice());
assert_eq!(view.buyer_workspace_notice, None);
@@ -13053,6 +13068,19 @@ mod tests {
}
#[test]
+ fn buyer_order_place_failure_uses_typed_visible_notice() {
+ let (mut view, _, home_dir) = test_home_view("buyer_notice");
+
+ assert!(view.place_personal_order_update());
+ assert_eq!(
+ view.buyer_workspace_notice.as_deref(),
+ Some(app_text(AppTextKey::PersonalOrderPlaceFailedNotice).as_str())
+ );
+
+ let _ = fs::remove_dir_all(home_dir);
+ }
+
+ #[test]
fn buyer_browse_refresh_failure_uses_typed_visible_notice() {
let (mut view, paths, home_dir) = test_home_view("buyer_notice");
block_shared_local_events_database(&paths);
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -124,6 +124,7 @@ define_app_text_keys! {
PersonalSearchPlaceholderBody => "personal.search.placeholder.body",
PersonalMarketplaceRefreshFailedNotice => "personal.marketplace.refresh_failed.notice",
PersonalDetailOpenFailedNotice => "personal.detail.open_failed.notice",
+ PersonalOrderPlaceFailedNotice => "personal.order_place_failed.notice",
PersonalCartPlaceholderBody => "personal.cart.placeholder.body",
PersonalOrdersSurfaceBody => "personal.orders.surface.body",
PersonalOrdersEmptyTitle => "personal.orders.empty.title",
diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs
@@ -362,6 +362,10 @@ mod tests {
app_text(AppTextKey::PersonalCheckoutLocalOnlyBody),
"This places a local order on this device. It does not charge a card."
);
+ assert_eq!(
+ app_text(AppTextKey::PersonalOrderPlaceFailedNotice),
+ "Couldn't place that order. Nothing was sent; check the order and try again."
+ );
}
#[test]
diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs
@@ -23,6 +23,41 @@ pub enum BuyerRepeatDemandApplyOutcome {
Unavailable,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BuyerOrderLocalEventExport {
+ pub order_id: OrderId,
+ pub farm_id: FarmId,
+ pub farm_display_name: String,
+ pub order_number: String,
+ pub status: String,
+ pub buyer_context_key: String,
+ pub buyer_name: String,
+ pub buyer_email: String,
+ pub buyer_phone: String,
+ pub buyer_order_note: String,
+ pub updated_at: String,
+ pub fulfillment_window_id: Option<FulfillmentWindowId>,
+ pub fulfillment_window_label: Option<String>,
+ pub fulfillment_starts_at: Option<String>,
+ pub fulfillment_ends_at: Option<String>,
+ pub lines: Vec<BuyerOrderLocalEventLine>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BuyerOrderLocalEventLine {
+ pub product_id: ProductId,
+ pub title: String,
+ pub quantity: u32,
+ pub quantity_unit_label: String,
+ pub quantity_display: String,
+ pub unit_price_minor_units: Option<u32>,
+ pub price_currency: String,
+ pub farm_key: Option<String>,
+ pub listing_addr: Option<String>,
+ pub listing_event_id: Option<String>,
+ pub seller_pubkey: Option<String>,
+}
+
pub struct AppBuyerRepository<'a> {
connection: &'a Connection,
}
@@ -578,6 +613,109 @@ impl<'a> AppBuyerRepository<'a> {
.transpose()
}
+ pub fn load_buyer_order_local_event_export(
+ &self,
+ context: &BuyerContext,
+ order_id: OrderId,
+ ) -> Result<Option<BuyerOrderLocalEventExport>, AppSqliteError> {
+ let context_key = context.storage_key();
+ let Some(record) = self
+ .connection
+ .query_row(
+ "select
+ o.id,
+ o.farm_id,
+ o.order_number,
+ o.status,
+ o.buyer_context_key,
+ o.customer_display_name,
+ o.buyer_email,
+ o.buyer_phone,
+ o.buyer_order_note,
+ o.updated_at,
+ f.display_name,
+ fw.id,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at
+ from orders o
+ inner join farms f on f.id = o.farm_id
+ left join fulfillment_windows fw on fw.id = o.fulfillment_window_id
+ where o.buyer_context_key = ?1 and o.id = ?2
+ limit 1",
+ params![context_key.as_str(), order_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, Option<String>>(4)?,
+ row.get::<_, String>(5)?,
+ row.get::<_, String>(6)?,
+ row.get::<_, String>(7)?,
+ row.get::<_, String>(8)?,
+ row.get::<_, String>(9)?,
+ row.get::<_, String>(10)?,
+ row.get::<_, Option<String>>(11)?,
+ row.get::<_, Option<String>>(12)?,
+ row.get::<_, Option<String>>(13)?,
+ row.get::<_, Option<String>>(14)?,
+ ))
+ },
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load buyer order local event export header",
+ source,
+ })?
+ else {
+ return Ok(None);
+ };
+ let (
+ order_id,
+ farm_id,
+ order_number,
+ status,
+ buyer_context_key,
+ buyer_name,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note,
+ updated_at,
+ farm_display_name,
+ fulfillment_window_id,
+ fulfillment_window_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ ) = record;
+ let order_id = parse_typed_id("orders.id", order_id)?;
+ let farm_id = parse_typed_id("orders.farm_id", farm_id)?;
+ let lines = self.load_buyer_order_local_event_lines(order_id)?;
+
+ Ok(Some(BuyerOrderLocalEventExport {
+ order_id,
+ farm_id,
+ farm_display_name,
+ order_number,
+ status,
+ buyer_context_key: buyer_context_key.unwrap_or_else(|| context_key.clone()),
+ buyer_name,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note,
+ updated_at,
+ fulfillment_window_id: parse_optional_typed_id(
+ "orders.fulfillment_window_id",
+ fulfillment_window_id,
+ )?,
+ fulfillment_window_label: empty_string_to_none_option(fulfillment_window_label),
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ lines,
+ }))
+ }
+
pub fn apply_buyer_repeat_demand_to_cart(
&self,
context: &BuyerContext,
@@ -1080,6 +1218,137 @@ impl<'a> AppBuyerRepository<'a> {
})
}
+ fn load_buyer_order_local_event_lines(
+ &self,
+ order_id: OrderId,
+ ) -> Result<Vec<BuyerOrderLocalEventLine>, AppSqliteError> {
+ let order_id_string = order_id.to_string();
+ let mut statement = self
+ .connection
+ .prepare(
+ "select
+ ol.id,
+ ol.title,
+ ol.quantity_value,
+ ol.quantity_unit_label,
+ ol.quantity_display,
+ p.price_minor_units,
+ p.price_currency,
+ (
+ select li.farm_key
+ from local_interop_imports li
+ where li.projected_kind = 'listing'
+ and li.projected_id = p.id
+ order by li.local_seq desc
+ limit 1
+ ),
+ (
+ select li.listing_addr
+ from local_interop_imports li
+ where li.projected_kind = 'listing'
+ and li.projected_id = p.id
+ and li.listing_addr is not null
+ and trim(li.listing_addr) <> ''
+ order by li.local_seq desc
+ limit 1
+ ),
+ (
+ select li.event_id
+ from local_interop_imports li
+ where li.projected_kind = 'listing'
+ and li.projected_id = p.id
+ and li.event_id is not null
+ and trim(li.event_id) <> ''
+ order by li.local_seq desc
+ limit 1
+ ),
+ (
+ select li.owner_pubkey
+ from local_interop_imports li
+ where li.projected_kind = 'listing'
+ and li.projected_id = p.id
+ and li.owner_pubkey is not null
+ and trim(li.owner_pubkey) <> ''
+ order by li.local_seq desc
+ limit 1
+ )
+ from order_lines ol
+ left join products p on p.id = substr(ol.id, length(?1) + 2)
+ where ol.order_id = ?1
+ order by ol.sort_index asc, ol.id asc",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare buyer order local event lines",
+ source,
+ })?;
+ let rows = statement
+ .query_map(params![order_id_string.as_str()], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ row.get::<_, Option<u32>>(5)?,
+ row.get::<_, Option<String>>(6)?,
+ row.get::<_, Option<String>>(7)?,
+ row.get::<_, Option<String>>(8)?,
+ row.get::<_, Option<String>>(9)?,
+ row.get::<_, Option<String>>(10)?,
+ ))
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query buyer order local event lines",
+ source,
+ })?;
+ let mut lines = Vec::new();
+
+ for row in rows {
+ let (
+ line_id,
+ title,
+ quantity,
+ quantity_unit_label,
+ quantity_display,
+ unit_price_minor_units,
+ price_currency,
+ farm_key,
+ listing_addr,
+ listing_event_id,
+ seller_pubkey,
+ ) = row.map_err(|source| AppSqliteError::Query {
+ operation: "read buyer order local event 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: "buyer order local event quantity must be non-negative",
+ })?;
+ if quantity == 0 {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer order local event quantity must be positive",
+ });
+ }
+
+ lines.push(BuyerOrderLocalEventLine {
+ product_id,
+ title,
+ quantity,
+ quantity_unit_label,
+ quantity_display,
+ unit_price_minor_units,
+ price_currency: price_currency.unwrap_or_else(|| "USD".to_owned()),
+ farm_key: farm_key.and_then(empty_string_to_none),
+ listing_addr: listing_addr.and_then(empty_string_to_none),
+ listing_event_id: listing_event_id.and_then(empty_string_to_none),
+ seller_pubkey: seller_pubkey.and_then(empty_string_to_none),
+ });
+ }
+
+ Ok(lines)
+ }
+
fn load_repeat_demand_order_lines(
&self,
order_id: OrderId,
@@ -1610,6 +1879,21 @@ fn parse_repeat_demand_product_id(line_id: &str) -> Result<ProductId, AppSqliteE
parse_typed_id("order_lines.id", product_id.to_owned())
}
+fn parse_order_line_product_id(
+ line_id: &str,
+ order_id: OrderId,
+) -> Result<ProductId, AppSqliteError> {
+ let order_id = order_id.to_string();
+ let prefix = format!("{order_id}:");
+ let Some(product_id) = line_id.strip_prefix(prefix.as_str()) else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer order local event line is missing its order id prefix",
+ });
+ };
+
+ parse_typed_id("order_lines.id", product_id.to_owned())
+}
+
fn refresh_buyer_cart_summary(cart: &mut BuyerCartProjection) -> Result<(), AppSqliteError> {
if cart.lines.is_empty() {
cart.subtotal_minor_units = None;
diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs
@@ -37,7 +37,10 @@ pub use activation::AppActivationRepository;
pub use activity::{
APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository,
};
-pub use buyer::{AppBuyerRepository, BuyerRepeatDemandApplyOutcome};
+pub use buyer::{
+ AppBuyerRepository, BuyerOrderLocalEventExport, BuyerOrderLocalEventLine,
+ BuyerRepeatDemandApplyOutcome,
+};
pub use error::AppSqliteError;
pub use farm_rules::{AppFarmRulesRepository, derive_farm_rules_readiness};
pub use farm_setup::AppFarmSetupRepository;
@@ -456,6 +459,15 @@ impl AppSqliteStore {
.load_buyer_order_detail(context, order_id)
}
+ pub fn load_buyer_order_local_event_export(
+ &self,
+ context: &BuyerContext,
+ order_id: OrderId,
+ ) -> Result<Option<BuyerOrderLocalEventExport>, AppSqliteError> {
+ self.buyer_repository()
+ .load_buyer_order_local_event_export(context, order_id)
+ }
+
pub fn apply_buyer_repeat_demand_to_cart(
&self,
context: &BuyerContext,
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -104,6 +104,7 @@
"personal.search.placeholder.body": "Search will use the same marketplace listings and stay focused on products, farms, and pickup options.",
"personal.marketplace.refresh_failed.notice": "Couldn't refresh marketplace listings. Your saved local state is still here; try again in a moment.",
"personal.detail.open_failed.notice": "Couldn't open that listing. Refresh the marketplace and try again.",
+ "personal.order_place_failed.notice": "Couldn't place that order. Nothing was sent; check the order and try again.",
"personal.cart.placeholder.body": "Add items from one farm to start an order.",
"personal.orders.surface.body": "Review orders placed on this device.",
"personal.orders.empty.title": "No orders yet",