app

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

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:
Mcrates/launchers/desktop/src/runtime.rs | 620++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/launchers/desktop/src/source_guards.rs | 1+
Mcrates/launchers/desktop/src/window.rs | 32++++++++++++++++++++++++++++++--
Mcrates/shared/i18n/src/keys.rs | 1+
Mcrates/shared/i18n/src/lib.rs | 4++++
Mcrates/shared/sqlite/src/buyer.rs | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/sqlite/src/lib.rs | 14+++++++++++++-
Mi18n/locales/en/messages.json | 1+
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",