app

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

commit 36bbbfe808fc7b9043daa27e98ec6f4a548c13ba
parent 280ffcdcf3b5f301db4a5127f94855e6e6d93bf7
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 17:36:49 -0700

buyer: align order review workflow

Diffstat:
Mcrates/desktop/src/runtime.rs | 180+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/desktop/src/source_guards.rs | 32++++++++++++++++----------------
Mcrates/desktop/src/window.rs | 189+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/i18n/src/keys.rs | 20++++++++++----------
Mcrates/i18n/src/lib.rs | 24+++++++++++++++---------
Mcrates/state/src/lib.rs | 4++--
Acrates/store/migrations/0023_order_workflow_display_projection.sql | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/interop.rs | 189++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/store/src/lib.rs | 16++++++++--------
Mcrates/store/src/migrations.rs | 4++++
Mcrates/store/src/repo/buyer.rs | 478+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/view/src/lib.rs | 63++++++++++++++++++++++++++++++++-------------------------------
Mi18n/locales/en/messages.json | 20++++++++++----------
13 files changed, 921 insertions(+), 354 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -44,7 +44,7 @@ use radroots_app_sync::{ use radroots_app_view::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, - BuyerCheckoutDraft, BuyerContext, BuyerOrderDetailProjection, BuyerOrderStatus, + BuyerContext, BuyerOrderDetailProjection, BuyerOrderReviewDraft, BuyerOrderStatus, BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, LoggedOutStartupProjection, OrderDetailProjection, OrderId, @@ -636,11 +636,12 @@ impl DesktopAppRuntime { self.lock_state_mut().remove_personal_cart_line(product_id) } - pub fn save_personal_checkout_draft( + pub fn save_personal_order_review_draft( &self, - draft: BuyerCheckoutDraft, + draft: BuyerOrderReviewDraft, ) -> Result<bool, AppSqliteError> { - self.lock_state_mut().save_personal_checkout_draft(draft) + self.lock_state_mut() + .save_personal_order_review_draft(draft) } pub fn place_personal_order(&self) -> Result<bool, AppSqliteError> { @@ -1751,15 +1752,15 @@ impl DesktopAppRuntimeState { let next_cart = next_buyer_cart_for_detail(current_cart, &detail, replace_existing)?; sqlite_store.replace_buyer_cart(&buyer_context, &next_cart)?; let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; - let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; let cart_changed = self.mutate_personal_projection(|projection| { let mut changed = false; if projection.cart.cart != refreshed_cart { projection.cart.cart = refreshed_cart.clone(); changed = true; } - if projection.cart.checkout != refreshed_checkout { - projection.cart.checkout = refreshed_checkout.clone(); + if projection.cart.order_review != refreshed_order_review { + projection.cart.order_review = refreshed_order_review.clone(); changed = true; } changed @@ -1797,28 +1798,28 @@ impl DesktopAppRuntimeState { } let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; - let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; - Ok(self.refresh_personal_cart_and_checkout(refreshed_cart, refreshed_checkout)) + Ok(self.refresh_personal_cart_and_order_review(refreshed_cart, refreshed_order_review)) } - fn save_personal_checkout_draft( + fn save_personal_order_review_draft( &mut self, - draft: BuyerCheckoutDraft, + draft: BuyerOrderReviewDraft, ) -> Result<bool, AppSqliteError> { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; let buyer_context = self.state_store.identity_projection().buyer_context(); - sqlite_store.save_buyer_checkout_draft(&buyer_context, &draft)?; - let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + sqlite_store.save_buyer_order_review_draft(&buyer_context, &draft)?; + let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; Ok(self.mutate_personal_projection(|projection| { - if projection.cart.checkout == refreshed_checkout { + if projection.cart.order_review == refreshed_order_review { return false; } - projection.cart.checkout = refreshed_checkout; + projection.cart.order_review = refreshed_order_review; true })) } @@ -1827,16 +1828,16 @@ impl DesktopAppRuntimeState { let buyer_context = self.state_store.identity_projection().buyer_context(); if matches!(buyer_context, BuyerContext::Guest) { return Err(AppSqliteError::InvalidProjection { - reason: "buyer checkout requires a selected account", + reason: "buyer order review requires a selected account", }); } - let (refreshed_cart, refreshed_checkout, refreshed_orders, order_detail, order_export) = { + let (refreshed_cart, refreshed_order_review, refreshed_orders, order_detail, order_export) = { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; let order_id = sqlite_store.place_buyer_order(&buyer_context)?; let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; - let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; let refreshed_orders = sqlite_store.load_buyer_orders(&buyer_context)?; if !refreshed_orders .rows @@ -1863,7 +1864,7 @@ impl DesktopAppRuntimeState { }; ( refreshed_cart, - refreshed_checkout, + refreshed_order_review, refreshed_orders, order_detail, order_export, @@ -1875,8 +1876,8 @@ impl DesktopAppRuntimeState { projection.cart.cart = refreshed_cart.clone(); changed = true; } - if projection.cart.checkout != refreshed_checkout { - projection.cart.checkout = refreshed_checkout.clone(); + if projection.cart.order_review != refreshed_order_review { + projection.cart.order_review = refreshed_order_review.clone(); changed = true; } if projection.orders.list != refreshed_orders { @@ -2022,7 +2023,7 @@ impl DesktopAppRuntimeState { .map(|detail| detail.order_id); let ( refreshed_cart, - refreshed_checkout, + refreshed_order_review, refreshed_orders, refreshed_order_detail, has_recoverable_coordination, @@ -2031,7 +2032,7 @@ impl DesktopAppRuntimeState { return Ok(false); }; let refreshed_cart = sqlite_store.load_buyer_cart(buyer_context)?; - let refreshed_checkout = sqlite_store.load_buyer_checkout(buyer_context)?; + let refreshed_order_review = sqlite_store.load_buyer_order_review(buyer_context)?; let refreshed_orders = sqlite_store.load_buyer_orders(buyer_context)?; let has_recoverable_coordination = !sqlite_store .load_recoverable_buyer_order_coordination_records(buyer_context)? @@ -2057,7 +2058,7 @@ impl DesktopAppRuntimeState { }; ( refreshed_cart, - refreshed_checkout, + refreshed_order_review, refreshed_orders, refreshed_order_detail, has_recoverable_coordination, @@ -2070,8 +2071,8 @@ impl DesktopAppRuntimeState { projection.cart.cart = refreshed_cart.clone(); changed = true; } - if projection.cart.checkout != refreshed_checkout { - projection.cart.checkout = refreshed_checkout.clone(); + if projection.cart.order_review != refreshed_order_review { + projection.cart.order_review = refreshed_order_review.clone(); changed = true; } if projection.orders.list != refreshed_orders { @@ -2124,7 +2125,8 @@ impl DesktopAppRuntimeState { )? { BuyerRepeatDemandApplyOutcome::Applied => { let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; - let refreshed_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + let refreshed_order_review = + sqlite_store.load_buyer_order_review(&buyer_context)?; let refreshed_orders = sqlite_store.load_buyer_orders(&buyer_context)?; let refreshed_detail = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)?; @@ -2134,8 +2136,8 @@ impl DesktopAppRuntimeState { projection.cart.cart = refreshed_cart.clone(); changed = true; } - if projection.cart.checkout != refreshed_checkout { - projection.cart.checkout = refreshed_checkout.clone(); + if projection.cart.order_review != refreshed_order_review { + projection.cart.order_review = refreshed_order_review.clone(); changed = true; } if projection.orders.list != refreshed_orders { @@ -5136,10 +5138,10 @@ impl DesktopAppRuntimeState { sqlite_store.load_buyer_listings(&query.search_query, &query.fulfillment_methods) } - fn refresh_personal_cart_and_checkout( + fn refresh_personal_cart_and_order_review( &mut self, refreshed_cart: BuyerCartProjection, - refreshed_checkout: radroots_app_view::BuyerCheckoutProjection, + refreshed_order_review: radroots_app_view::BuyerOrderReviewProjection, ) -> bool { self.mutate_personal_projection(|projection| { let mut changed = false; @@ -5147,8 +5149,8 @@ impl DesktopAppRuntimeState { projection.cart.cart = refreshed_cart.clone(); changed = true; } - if projection.cart.checkout != refreshed_checkout { - projection.cart.checkout = refreshed_checkout.clone(); + if projection.cart.order_review != refreshed_order_review { + projection.cart.order_review = refreshed_order_review.clone(); changed = true; } @@ -7985,7 +7987,7 @@ fn load_selected_account_context_with_options( None => None, }; let buyer_cart = sqlite_store.load_buyer_cart(&buyer_context)?; - let buyer_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; + let buyer_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; let buyer_orders = sqlite_store.load_buyer_orders(&buyer_context)?; let has_recoverable_coordination = !sqlite_store .load_recoverable_buyer_order_coordination_records(&buyer_context)? @@ -8006,7 +8008,7 @@ fn load_selected_account_context_with_options( }, cart: BuyerCartScreenProjection { cart: buyer_cart, - checkout: buyer_checkout, + order_review: buyer_order_review, }, orders: BuyerOrdersScreenProjection { list: buyer_orders, @@ -9615,20 +9617,21 @@ mod tests { use radroots_app_view::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId, - BlackoutPeriodRecord, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerOrderStatus, - FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, - FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, - FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, - FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, - PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, - PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, - PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, - PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId, - ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, - RecoveryRecordId, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + BlackoutPeriodRecord, BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft, + BuyerOrderStatus, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, + FarmReadiness, FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, + FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection, + FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, + OrderStatus, OrdersFilter, PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, + PackDayBatchPrintStatus, PackDayExportInstanceId, PackDayExportStatus, + PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, + PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, + PackDayProjection, PackDayRosterRow, PersonalSection, PickupLocationId, + PickupLocationRecord, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductStatus, + ProductsFilter, ProductsSort, RecoveryKind, RecoveryRecordId, ReminderDeliveryState, + ReminderFeedProjection, ReminderKind, SelectedAccountProjection, SelectedSurfaceProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, + TodaySetupTaskKind, TodaySummary, }; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, @@ -14275,7 +14278,7 @@ mod tests { } #[test] - fn runtime_removing_buyer_cart_line_clears_cart_and_checkout_readiness() { + fn runtime_removing_buyer_cart_line_clears_cart_and_order_review_readiness() { let runtime = memory_runtime(); let (account_id, farm_id) = provision_ready_farmer_account(&runtime); assert!( @@ -14331,9 +14334,20 @@ mod tests { let summary = runtime.summary(); assert!(summary.personal_projection.cart.cart.lines.is_empty()); assert!(summary.personal_projection.cart.cart.farm_id.is_none()); - assert!(!summary.personal_projection.cart.checkout.can_place_order); + assert!( + !summary + .personal_projection + .cart + .order_review + .can_place_order + ); assert_eq!( - summary.personal_projection.cart.checkout.summary.line_count, + summary + .personal_projection + .cart + .order_review + .summary + .line_count, 0 ); } @@ -14387,17 +14401,17 @@ mod tests { ); assert!( runtime - .save_personal_checkout_draft(BuyerCheckoutDraft { + .save_personal_order_review_draft(BuyerOrderReviewDraft { 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") + .expect("buyer order review draft should save") ); - let checkout = runtime.summary().personal_projection.cart.checkout; - assert!(checkout.can_place_order); - assert_eq!(checkout.place_order_disabled_reason, None); + let order_review = runtime.summary().personal_projection.cart.order_review; + assert!(order_review.can_place_order); + assert_eq!(order_review.place_order_disabled_reason, None); assert!( runtime .place_personal_order() @@ -14410,14 +14424,20 @@ mod tests { ShellSection::Personal(PersonalSection::Orders) ); assert!(summary.personal_projection.cart.cart.lines.is_empty()); - assert!(!summary.personal_projection.cart.checkout.can_place_order); + assert!( + !summary + .personal_projection + .cart + .order_review + .can_place_order + ); assert_eq!( summary .personal_projection .cart - .checkout + .order_review .place_order_disabled_reason, - Some(BuyerCheckoutDisabledReason::EmptyCart) + Some(BuyerOrderReviewDisabledReason::EmptyCart) ); assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); assert_eq!( @@ -14454,7 +14474,7 @@ mod tests { } #[test] - fn runtime_guest_checkout_requires_account_before_order_write() { + fn runtime_guest_order_review_requires_account_before_order_write() { let runtime = memory_runtime(); let farm_id = FarmId::new(); runtime @@ -14513,13 +14533,13 @@ mod tests { ); assert!( runtime - .save_personal_checkout_draft(BuyerCheckoutDraft { + .save_personal_order_review_draft(BuyerOrderReviewDraft { 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") + .expect("buyer order review draft should save") ); let ready_summary = runtime.summary(); @@ -14527,22 +14547,22 @@ mod tests { !ready_summary .personal_projection .cart - .checkout + .order_review .can_place_order ); assert_eq!( ready_summary .personal_projection .cart - .checkout + .order_review .place_order_disabled_reason, - Some(BuyerCheckoutDisabledReason::AccountRequired) + Some(BuyerOrderReviewDisabledReason::AccountRequired) ); assert_eq!( ready_summary .personal_projection .cart - .checkout + .order_review .summary .line_count, 1 @@ -14550,7 +14570,7 @@ mod tests { let error = runtime .place_personal_order() - .expect_err("guest checkout should require an account"); + .expect_err("guest order review should require an account"); assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); let summary = runtime.summary(); @@ -14982,13 +15002,13 @@ mod tests { .expect("listing projection should mutate after cart snapshot"); assert!( runtime - .save_personal_checkout_draft(BuyerCheckoutDraft { + .save_personal_order_review_draft(BuyerOrderReviewDraft { 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") + .expect("buyer order review draft should save") ); assert!( runtime @@ -15433,13 +15453,13 @@ mod tests { ); assert!( runtime - .save_personal_checkout_draft(BuyerCheckoutDraft { + .save_personal_order_review_draft(BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), order_note: String::new(), }) - .expect("buyer checkout draft should save") + .expect("buyer order review draft should save") ); assert!( runtime @@ -15545,13 +15565,13 @@ mod tests { ); assert!( runtime - .save_personal_checkout_draft(BuyerCheckoutDraft { + .save_personal_order_review_draft(BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), order_note: String::new(), }) - .expect("buyer checkout draft should save") + .expect("buyer order review draft should save") ); assert!( runtime @@ -19515,13 +19535,13 @@ mod tests { ); assert!( runtime - .save_personal_checkout_draft(BuyerCheckoutDraft { + .save_personal_order_review_draft(BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), order_note: String::new(), }) - .expect("buyer checkout draft should save") + .expect("buyer order review draft should save") ); block_shared_local_events_database(&paths); @@ -19542,7 +19562,13 @@ mod tests { .has_recoverable_coordination ); assert!(summary.personal_projection.cart.cart.lines.is_empty()); - assert!(!summary.personal_projection.cart.checkout.can_place_order); + assert!( + !summary + .personal_projection + .cart + .order_review + .can_place_order + ); assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); let visible_order_id = summary.personal_projection.orders.list.rows[0].order_id; let order_detail = summary diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -45,10 +45,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer-detail-keep-current", "buyer-detail-quantity-decrease", "buyer-detail-quantity-increase", - "buyer-cart-open-checkout", + "buyer-cart-open-order-review", "buyer-cart-remove-line", - "buyer-checkout-back", - "buyer-checkout-place-order", + "buyer-order-review-back", + "buyer-order-review-place-order", "buyer-listing-open", "buyer-order-accept-change", "buyer-order-confirm-replace", @@ -61,8 +61,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "personal_orders", "buyer.add_to_cart_failed", "buyer.cart_remove_failed", - "buyer.checkout_place_failed", - "buyer.checkout_save_failed", + "buyer.order_review_place_failed", + "buyer.order_review_save_failed", "buyer.detail_open_failed", "buyer.order_open_failed", "buyer.order_cancel_failed", @@ -88,7 +88,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to retry buyer order coordination", "failed to remove buyer cart line", "failed to reorder buyer order", - "failed to save buyer checkout draft", + "failed to save buyer order review draft", "failed to select buyer section", "failed to open buyer product detail", "failed to update buyer fulfillment filter", @@ -423,7 +423,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrderSummaryTitle", "AppTextKey::PersonalFulfillmentTitle", "AppTextKey::PersonalCartRemoveLineAction", - "AppTextKey::PersonalCartContinueCheckoutAction", + "AppTextKey::PersonalCartReviewOrderAction", "AppTextKey::PersonalCartLineQuantityLabel", "AppTextKey::PersonalCartLineUnitPriceLabel", "AppTextKey::PersonalCartLineTotalLabel", @@ -437,15 +437,15 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalDetailReplaceCartBody", "AppTextKey::PersonalDetailReplaceCartAction", "AppTextKey::PersonalDetailKeepCurrentCartAction", - "AppTextKey::PersonalCheckoutTitle", - "AppTextKey::PersonalCheckoutBackAction", - "AppTextKey::PersonalCheckoutContactTitle", - "AppTextKey::PersonalCheckoutFieldName", - "AppTextKey::PersonalCheckoutFieldEmail", - "AppTextKey::PersonalCheckoutFieldPhone", - "AppTextKey::PersonalCheckoutFieldOrderNote", - "AppTextKey::PersonalCheckoutLocalOnlyBody", - "AppTextKey::PersonalCheckoutPlaceOrderAction", + "AppTextKey::PersonalOrderReviewTitle", + "AppTextKey::PersonalOrderReviewBackAction", + "AppTextKey::PersonalOrderReviewContactTitle", + "AppTextKey::PersonalOrderReviewFieldName", + "AppTextKey::PersonalOrderReviewFieldEmail", + "AppTextKey::PersonalOrderReviewFieldPhone", + "AppTextKey::PersonalOrderReviewFieldOrderNote", + "AppTextKey::PersonalOrderReviewLocalOnlyBody", + "AppTextKey::PersonalOrderReviewPlaceOrderAction", "AppTextKey::HomeTodayOpenInOrdersAction", "AppTextKey::HomeTodayOpenInPackDayAction", "AppTextKey::OrdersTitle", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -48,8 +48,8 @@ use radroots_app_ui::{ pub use radroots_app_view::SettingsSection as SettingsPanelViewKey; use radroots_app_view::{ AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCartProjection, - BuyerCartReplaceConfirmationProjection, BuyerCheckoutDraft, BuyerCheckoutSummaryProjection, - BuyerListingRow, BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, + BuyerCartReplaceConfirmationProjection, BuyerListingRow, BuyerOrderDetailProjection, + BuyerOrderReviewDraft, BuyerOrderReviewSummaryProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerProductDetailProjection, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, @@ -220,7 +220,7 @@ pub struct HomeView { startup_signer_recovery_attempted: bool, farm_setup_form: Option<FarmSetupFormState>, personal_search: Option<PersonalSearchState>, - buyer_checkout_form: Option<BuyerCheckoutFormState>, + buyer_order_review_form: Option<BuyerOrderReviewFormState>, products_search: Option<ProductsSearchState>, products_stock_editor: Option<ProductsStockEditorState>, product_editor_form: Option<ProductEditorFormState>, @@ -263,7 +263,7 @@ struct HomeAutoFocusState { startup_signer_input_is_editable: bool, has_farm_setup_form: bool, has_personal_search_input: bool, - has_buyer_checkout_form: bool, + has_buyer_order_review_form: bool, has_products_search_input: bool, has_products_stock_editor: bool, has_product_editor_form: bool, @@ -278,8 +278,8 @@ enum HomeAutoFocusTarget { BuyerSearchInput, BuyerListingOpenFirst, BuyerDetailBack, - BuyerCartOpenCheckout, - BuyerCheckoutNameInput, + BuyerCartOpenOrderReview, + BuyerOrderReviewNameInput, BuyerOrderOpenFirst, BuyerOrderConfirmReplace, BuyerOrderRepeatDemand, @@ -336,7 +336,7 @@ impl HomeView { startup_signer_recovery_attempted: false, farm_setup_form: None, personal_search: None, - buyer_checkout_form: None, + buyer_order_review_form: None, products_search: None, products_stock_editor: None, product_editor_form: None, @@ -353,7 +353,7 @@ impl HomeView { ), has_farm_setup_form: self.farm_setup_form.is_some(), has_personal_search_input: self.personal_search.is_some(), - has_buyer_checkout_form: self.buyer_checkout_form.is_some(), + has_buyer_order_review_form: self.buyer_order_review_form.is_some(), has_products_search_input: self.products_search.is_some(), has_products_stock_editor: self.products_stock_editor.is_some(), has_product_editor_form: self.product_editor_form.is_some(), @@ -404,11 +404,11 @@ impl HomeView { HomeAutoFocusTarget::BuyerDetailBack => { focus_button(window, "buyer-detail-back", cx); } - HomeAutoFocusTarget::BuyerCartOpenCheckout => { - focus_button(window, "buyer-cart-open-checkout", cx); + HomeAutoFocusTarget::BuyerCartOpenOrderReview => { + focus_button(window, "buyer-cart-open-order-review", cx); } - HomeAutoFocusTarget::BuyerCheckoutNameInput => { - if let Some(form) = self.buyer_checkout_form.as_ref() { + HomeAutoFocusTarget::BuyerOrderReviewNameInput => { + if let Some(form) = self.buyer_order_review_form.as_ref() { form.name_input .update(cx, |input, cx| input.focus(window, cx)); } @@ -1029,7 +1029,7 @@ impl HomeView { } } - fn sync_buyer_checkout_form( + fn sync_buyer_order_review_form( &mut self, runtime_summary: &DesktopAppRuntimeSummary, window: &mut Window, @@ -1044,25 +1044,29 @@ impl HomeView { .lines .is_empty() { - self.buyer_checkout_form = None; + self.buyer_order_review_form = None; return; } let workspace_id = personal_workspace_id(runtime_summary); - let draft = &runtime_summary.personal_projection.cart.checkout.draft; + let draft = &runtime_summary.personal_projection.cart.order_review.draft; let should_reset = self - .buyer_checkout_form + .buyer_order_review_form .as_ref() .map(|form| form.workspace_id != workspace_id) .unwrap_or(false); if should_reset { - self.buyer_checkout_form = - Some(BuyerCheckoutFormState::new(workspace_id, draft, window, cx)); + self.buyer_order_review_form = Some(BuyerOrderReviewFormState::new( + workspace_id, + draft, + window, + cx, + )); return; } - if let Some(form) = self.buyer_checkout_form.as_mut() { + if let Some(form) = self.buyer_order_review_form.as_mut() { form.sync(draft, window, cx); } } @@ -1323,7 +1327,7 @@ impl HomeView { } } - fn handle_buyer_checkout_input_event( + fn handle_buyer_order_review_input_event( &mut self, state: &Entity<InputState>, event: &InputEvent, @@ -1334,7 +1338,7 @@ impl HomeView { return; } - let Some(form) = self.buyer_checkout_form.as_ref() else { + let Some(form) = self.buyer_order_review_form.as_ref() else { return; }; let matches_input = form.name_input == *state @@ -1347,16 +1351,16 @@ impl HomeView { match self .runtime - .save_personal_checkout_draft(form.current_draft(cx)) + .save_personal_order_review_draft(form.current_draft(cx)) { Ok(true) => cx.notify(), Ok(false) => {} Err(runtime_error) => { error!( target: "buyer", - event = "buyer.checkout_save_failed", + event = "buyer.order_review_save_failed", error = %runtime_error, - "failed to save buyer checkout draft" + "failed to save buyer order review draft" ); } } @@ -1499,8 +1503,8 @@ impl HomeView { } } - fn open_personal_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) { - if self.buyer_checkout_form.is_some() { + fn open_personal_order_review(&mut self, window: &mut Window, cx: &mut Context<Self>) { + if self.buyer_order_review_form.is_some() { return; } @@ -1517,17 +1521,17 @@ impl HomeView { return; } - self.buyer_checkout_form = Some(BuyerCheckoutFormState::new( + self.buyer_order_review_form = Some(BuyerOrderReviewFormState::new( personal_workspace_id(&runtime_summary), - &runtime_summary.personal_projection.cart.checkout.draft, + &runtime_summary.personal_projection.cart.order_review.draft, window, cx, )); cx.notify(); } - fn close_personal_checkout(&mut self, cx: &mut Context<Self>) { - if self.buyer_checkout_form.take().is_some() { + fn close_personal_order_review(&mut self, cx: &mut Context<Self>) { + if self.buyer_order_review_form.take().is_some() { cx.notify(); } } @@ -1557,7 +1561,7 @@ impl HomeView { fn place_personal_order_update(&mut self) -> bool { match self.runtime.place_personal_order() { Ok(true) => { - self.buyer_checkout_form = None; + self.buyer_order_review_form = None; let _ = self.clear_buyer_workspace_notice(); true } @@ -1565,11 +1569,11 @@ impl HomeView { Err(runtime_error) => { let notice = buyer_order_place_failure_notice(&runtime_error); if notice == BuyerWorkspaceNotice::OrderCoordinationFailed { - self.buyer_checkout_form = None; + self.buyer_order_review_form = None; } error!( target: "buyer", - event = "buyer.checkout_place_failed", + event = "buyer.order_review_place_failed", error = %runtime_error, "failed to place buyer order" ); @@ -3190,7 +3194,7 @@ impl HomeView { cx: &mut Context<Self>, ) -> AnyElement { let cart = &runtime.personal_projection.cart.cart; - let checkout = &runtime.personal_projection.cart.checkout; + let order_review = &runtime.personal_projection.cart.order_review; app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() @@ -3210,15 +3214,15 @@ impl HomeView { .w_full() .child(buyer_cart_card( cart, - &checkout.summary, - self.buyer_checkout_form.is_some(), + &order_review.summary, + self.buyer_order_review_form.is_some(), cx, )) - .when_some(self.buyer_checkout_form.as_ref(), |this, form| { - this.child(buyer_checkout_card( + .when_some(self.buyer_order_review_form.as_ref(), |this, form| { + this.child(buyer_order_review_card( form, - checkout, - cx.listener(|this, _, _, cx| this.close_personal_checkout(cx)), + order_review, + cx.listener(|this, _, _, cx| this.close_personal_order_review(cx)), cx.listener(|this, _, _, cx| this.place_personal_order(cx)), cx, )) @@ -4573,7 +4577,7 @@ impl Render for HomeView { self.sync_startup_signer_entry(&runtime_summary, window, cx); self.sync_farm_setup_form(&runtime_summary, window, cx); self.sync_personal_search(&runtime_summary, window, cx); - self.sync_buyer_checkout_form(&runtime_summary, window, cx); + self.sync_buyer_order_review_form(&runtime_summary, window, cx); self.sync_products_search(&runtime_summary, window, cx); self.sync_products_stock_editor(&runtime_summary); self.sync_product_editor_form(&runtime_summary, window, cx); @@ -4698,7 +4702,7 @@ impl PersonalSearchState { } } -struct BuyerCheckoutFormState { +struct BuyerOrderReviewFormState { workspace_id: String, name_input: Entity<InputState>, email_input: Entity<InputState>, @@ -4710,10 +4714,10 @@ struct BuyerCheckoutFormState { _order_note_subscription: Subscription, } -impl BuyerCheckoutFormState { +impl BuyerOrderReviewFormState { fn new( workspace_id: String, - draft: &BuyerCheckoutDraft, + draft: &BuyerOrderReviewDraft, window: &mut Window, cx: &mut Context<HomeView>, ) -> Self { @@ -4727,22 +4731,22 @@ impl BuyerCheckoutFormState { let name_subscription = cx.subscribe_in( &name_input, window, - HomeView::handle_buyer_checkout_input_event, + HomeView::handle_buyer_order_review_input_event, ); let email_subscription = cx.subscribe_in( &email_input, window, - HomeView::handle_buyer_checkout_input_event, + HomeView::handle_buyer_order_review_input_event, ); let phone_subscription = cx.subscribe_in( &phone_input, window, - HomeView::handle_buyer_checkout_input_event, + HomeView::handle_buyer_order_review_input_event, ); let order_note_subscription = cx.subscribe_in( &order_note_input, window, - HomeView::handle_buyer_checkout_input_event, + HomeView::handle_buyer_order_review_input_event, ); Self { @@ -4760,14 +4764,14 @@ impl BuyerCheckoutFormState { fn sync( &mut self, - draft: &BuyerCheckoutDraft, + draft: &BuyerOrderReviewDraft, window: &mut Window, cx: &mut Context<HomeView>, ) { - sync_checkout_input(&self.name_input, draft.name.as_str(), window, cx); - sync_checkout_input(&self.email_input, draft.email.as_str(), window, cx); - sync_checkout_input(&self.phone_input, draft.phone.as_str(), window, cx); - sync_checkout_input( + sync_order_review_input(&self.name_input, draft.name.as_str(), window, cx); + sync_order_review_input(&self.email_input, draft.email.as_str(), window, cx); + sync_order_review_input(&self.phone_input, draft.phone.as_str(), window, cx); + sync_order_review_input( &self.order_note_input, draft.order_note.as_str(), window, @@ -4775,8 +4779,8 @@ impl BuyerCheckoutFormState { ); } - fn current_draft(&self, cx: &App) -> BuyerCheckoutDraft { - BuyerCheckoutDraft { + fn current_draft(&self, cx: &App) -> BuyerOrderReviewDraft { + BuyerOrderReviewDraft { name: self.name_input.read(cx).value().to_string(), email: self.email_input.read(cx).value().to_string(), phone: self.phone_input.read(cx).value().to_string(), @@ -4785,7 +4789,7 @@ impl BuyerCheckoutFormState { } } -fn sync_checkout_input( +fn sync_order_review_input( input: &Entity<InputState>, value: &str, window: &mut Window, @@ -7607,10 +7611,10 @@ fn buyer_auto_focus_target( } } PersonalSection::Cart => { - if state.has_buyer_checkout_form { - Some(HomeAutoFocusTarget::BuyerCheckoutNameInput) + if state.has_buyer_order_review_form { + Some(HomeAutoFocusTarget::BuyerOrderReviewNameInput) } else if !runtime.personal_projection.cart.cart.lines.is_empty() { - Some(HomeAutoFocusTarget::BuyerCartOpenCheckout) + Some(HomeAutoFocusTarget::BuyerCartOpenOrderReview) } else { None } @@ -8410,8 +8414,8 @@ fn buyer_product_detail_card( fn buyer_cart_card( cart: &BuyerCartProjection, - summary: &BuyerCheckoutSummaryProjection, - checkout_open: bool, + summary: &BuyerOrderReviewSummaryProjection, + order_review_open: bool, cx: &mut Context<HomeView>, ) -> impl IntoElement { app_surface_card( @@ -8433,11 +8437,11 @@ fn buyer_cart_card( ))) .child(label_value_list(buyer_order_summary_rows(summary))), )) - .when(!checkout_open, |this| { + .when(!order_review_open, |this| { this.child(action_button_primary( - "buyer-cart-open-checkout", - app_shared_text(AppTextKey::PersonalCartContinueCheckoutAction), - cx.listener(|this, _, window, cx| this.open_personal_checkout(window, cx)), + "buyer-cart-open-order-review", + app_shared_text(AppTextKey::PersonalCartReviewOrderAction), + cx.listener(|this, _, window, cx| this.open_personal_order_review(window, cx)), cx, )) }), @@ -8498,9 +8502,9 @@ fn buyer_cart_line_card( ) } -fn buyer_checkout_card( - form: &BuyerCheckoutFormState, - checkout: &radroots_app_view::BuyerCheckoutProjection, +fn buyer_order_review_card( + form: &BuyerOrderReviewFormState, + order_review: &radroots_app_view::BuyerOrderReviewProjection, on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_place_order: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, @@ -8516,17 +8520,17 @@ fn buyer_checkout_card( .justify_between() .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child(app_text_value(app_shared_text( - AppTextKey::PersonalCheckoutTitle, + AppTextKey::PersonalOrderReviewTitle, ))) .child(text_button( - "buyer-checkout-back", - app_shared_text(AppTextKey::PersonalCheckoutBackAction), + "buyer-order-review-back", + app_shared_text(AppTextKey::PersonalOrderReviewBackAction), on_close, cx, )), ) .child(home_body_text(app_shared_text( - AppTextKey::PersonalCheckoutLocalOnlyBody, + AppTextKey::PersonalOrderReviewLocalOnlyBody, ))) .child(app_surface_panel( app_stack_v(APP_UI_THEME.foundation.spacing.small_px) @@ -8536,7 +8540,7 @@ fn buyer_checkout_card( AppTextKey::PersonalOrderSummaryTitle, ))) .child(label_value_list(buyer_order_summary_rows( - &checkout.summary, + &order_review.summary, ))), )) .child(app_surface_panel( @@ -8547,7 +8551,7 @@ fn buyer_checkout_card( AppTextKey::PersonalFulfillmentTitle, ))) .child(home_body_text( - checkout + order_review .summary .fulfillment_summary .clone() @@ -8555,7 +8559,7 @@ fn buyer_checkout_card( )), )) .child(app_form_section( - app_shared_text(AppTextKey::PersonalCheckoutContactTitle), + app_shared_text(AppTextKey::PersonalOrderReviewContactTitle), div() .w_full() .flex() @@ -8563,7 +8567,7 @@ fn buyer_checkout_card( .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) .child(app_form_input_text( AppFormFieldSpec::new( - app_shared_text(AppTextKey::PersonalCheckoutFieldName), + app_shared_text(AppTextKey::PersonalOrderReviewFieldName), Option::<SharedString>::None, ), &form.name_input, @@ -8571,7 +8575,7 @@ fn buyer_checkout_card( )) .child(app_form_input_text( AppFormFieldSpec::new( - app_shared_text(AppTextKey::PersonalCheckoutFieldEmail), + app_shared_text(AppTextKey::PersonalOrderReviewFieldEmail), Option::<SharedString>::None, ), &form.email_input, @@ -8579,7 +8583,7 @@ fn buyer_checkout_card( )) .child(app_form_input_text( AppFormFieldSpec::new( - app_shared_text(AppTextKey::PersonalCheckoutFieldPhone), + app_shared_text(AppTextKey::PersonalOrderReviewFieldPhone), Option::<SharedString>::None, ), &form.phone_input, @@ -8587,25 +8591,25 @@ fn buyer_checkout_card( )) .child(app_form_input_text( AppFormFieldSpec::new( - app_shared_text(AppTextKey::PersonalCheckoutFieldOrderNote), + app_shared_text(AppTextKey::PersonalOrderReviewFieldOrderNote), Option::<SharedString>::None, ), &form.order_note_input, false, )), )) - .child(if checkout.can_place_order { + .child(if order_review.can_place_order { action_button_primary( - "buyer-checkout-place-order", - app_shared_text(AppTextKey::PersonalCheckoutPlaceOrderAction), + "buyer-order-review-place-order", + app_shared_text(AppTextKey::PersonalOrderReviewPlaceOrderAction), on_place_order, cx, ) .into_any_element() } else { action_button_primary_disabled( - "buyer-checkout-place-order", - app_shared_text(AppTextKey::PersonalCheckoutPlaceOrderAction), + "buyer-order-review-place-order", + app_shared_text(AppTextKey::PersonalOrderReviewPlaceOrderAction), cx, ) .into_any_element() @@ -8613,7 +8617,7 @@ fn buyer_checkout_card( ) } -fn buyer_order_summary_rows(summary: &BuyerCheckoutSummaryProjection) -> Vec<LabelValueRow> { +fn buyer_order_summary_rows(summary: &BuyerOrderReviewSummaryProjection) -> Vec<LabelValueRow> { vec![ LabelValueRow::new( app_shared_text(AppTextKey::PersonalSummaryFarmLabel), @@ -8891,7 +8895,10 @@ fn buyer_orders_list_entry( .flex_1() .min_w_0() .child(app_text_label(row.order_number.clone())) - .child(settings_badge_text(row.farm_display_name.clone())), + .child(settings_badge_text(row.farm_display_name.clone())) + .child(settings_badge_text(trade_economics_total_text( + &row.workflow.economics, + ))), ) .child( div() @@ -13812,20 +13819,20 @@ mod tests { Some(HomeAutoFocusTarget::BuyerSearchInput) ); - let mut buyer_cart_checkout = buyer_search.clone(); - buyer_cart_checkout.shell_projection = AppShellProjection::new( + let mut buyer_cart_order_review = buyer_search.clone(); + buyer_cart_order_review.shell_projection = AppShellProjection::new( ActiveSurface::Personal, ShellSection::Personal(PersonalSection::Cart), ); assert_eq!( home_auto_focus_target( - &buyer_cart_checkout, + &buyer_cart_order_review, HomeAutoFocusState { - has_buyer_checkout_form: true, + has_buyer_order_review_form: true, ..HomeAutoFocusState::default() }, ), - Some(HomeAutoFocusTarget::BuyerCheckoutNameInput) + Some(HomeAutoFocusTarget::BuyerOrderReviewNameInput) ); let order_id = OrderId::new(); diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -163,7 +163,7 @@ define_app_text_keys! { PersonalOrderSummaryTitle => "personal.order_summary.title", PersonalFulfillmentTitle => "personal.fulfillment.title", PersonalCartRemoveLineAction => "personal.cart.remove_line.action", - PersonalCartContinueCheckoutAction => "personal.cart.continue_checkout.action", + PersonalCartReviewOrderAction => "personal.cart.review_order.action", PersonalCartLineQuantityLabel => "personal.cart.line.quantity.label", PersonalCartLineUnitPriceLabel => "personal.cart.line.unit_price.label", PersonalCartLineTotalLabel => "personal.cart.line.total.label", @@ -177,15 +177,15 @@ define_app_text_keys! { PersonalDetailReplaceCartBody => "personal.detail.replace_cart.body", PersonalDetailReplaceCartAction => "personal.detail.replace_cart.action", PersonalDetailKeepCurrentCartAction => "personal.detail.keep_current_cart.action", - PersonalCheckoutTitle => "personal.checkout.title", - PersonalCheckoutBackAction => "personal.checkout.back_action", - PersonalCheckoutContactTitle => "personal.checkout.contact.title", - PersonalCheckoutFieldName => "personal.checkout.field.name", - PersonalCheckoutFieldEmail => "personal.checkout.field.email", - PersonalCheckoutFieldPhone => "personal.checkout.field.phone", - PersonalCheckoutFieldOrderNote => "personal.checkout.field.order_note", - PersonalCheckoutLocalOnlyBody => "personal.checkout.local_only.body", - PersonalCheckoutPlaceOrderAction => "personal.checkout.place_order.action", + PersonalOrderReviewTitle => "personal.order_review.title", + PersonalOrderReviewBackAction => "personal.order_review.back_action", + PersonalOrderReviewContactTitle => "personal.order_review.contact.title", + PersonalOrderReviewFieldName => "personal.order_review.field.name", + PersonalOrderReviewFieldEmail => "personal.order_review.field.email", + PersonalOrderReviewFieldPhone => "personal.order_review.field.phone", + PersonalOrderReviewFieldOrderNote => "personal.order_review.field.order_note", + PersonalOrderReviewLocalOnlyBody => "personal.order_review.local_only.body", + PersonalOrderReviewPlaceOrderAction => "personal.order_review.place_order.action", OrdersTitle => "orders.title", OrdersFiltersTitle => "orders.filters.title", OrdersSummaryTotal => "orders.summary.total", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -353,18 +353,21 @@ mod tests { } #[test] - fn english_marketplace_checkout_copy_matches_the_local_order_contract() { + fn english_marketplace_order_review_copy_matches_the_local_order_contract() { assert_eq!( - app_text(AppTextKey::PersonalCartContinueCheckoutAction), + app_text(AppTextKey::PersonalCartReviewOrderAction), "Review order" ); - assert_eq!(app_text(AppTextKey::PersonalCheckoutTitle), "Order review"); assert_eq!( - app_text(AppTextKey::PersonalCheckoutPlaceOrderAction), + app_text(AppTextKey::PersonalOrderReviewTitle), + "Order review" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrderReviewPlaceOrderAction), "Place order" ); assert_eq!( - app_text(AppTextKey::PersonalCheckoutLocalOnlyBody), + app_text(AppTextKey::PersonalOrderReviewLocalOnlyBody), "Review the details before placing the order." ); assert_eq!( @@ -380,9 +383,9 @@ mod tests { #[test] fn english_payment_action_copy_remains_unspoken_for_reserved_workflow() { let action_keys = [ - AppTextKey::PersonalCartContinueCheckoutAction, - AppTextKey::PersonalCheckoutBackAction, - AppTextKey::PersonalCheckoutPlaceOrderAction, + AppTextKey::PersonalCartReviewOrderAction, + AppTextKey::PersonalOrderReviewBackAction, + AppTextKey::PersonalOrderReviewPlaceOrderAction, AppTextKey::OrdersRecoveryActionOpenFollowUp, AppTextKey::OrdersRecoveryActionStartReview, AppTextKey::OrdersRecoveryActionMarkOpen, @@ -398,6 +401,9 @@ mod tests { "bank", "card", "processor", + "provider", + "payment-provider", + "payment provider", ]; for key in action_keys { @@ -408,7 +414,7 @@ mod tests { } for copy in [ - app_text(AppTextKey::PersonalCheckoutLocalOnlyBody), + app_text(AppTextKey::PersonalOrderReviewLocalOnlyBody), app_text(AppTextKey::PersonalOrderCoordinationFailedNotice), ] { let normalized_copy = copy.to_lowercase(); diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs @@ -13,7 +13,7 @@ use radroots_app_sync::{ }; use radroots_app_view::{ ActiveSurface, AppIdentityProjection, AppStartupGate, BuyerCartProjection, - BuyerCheckoutProjection, BuyerListingsProjection, BuyerOrderDetailProjection, + BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderReviewProjection, BuyerOrdersProjection, BuyerProductDetailProjection, FarmOrderMethod, FarmReadiness, FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, @@ -125,7 +125,7 @@ pub struct BuyerSearchScreenProjection { #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct BuyerCartScreenProjection { pub cart: BuyerCartProjection, - pub checkout: BuyerCheckoutProjection, + pub order_review: BuyerOrderReviewProjection, } #[derive(Clone, Debug, Default, Eq, PartialEq)] diff --git a/crates/store/migrations/0023_order_workflow_display_projection.sql b/crates/store/migrations/0023_order_workflow_display_projection.sql @@ -0,0 +1,56 @@ +ALTER TABLE orders + ADD COLUMN workflow_agreement TEXT NOT NULL DEFAULT 'ordered' CHECK ( + workflow_agreement IN ('ordered', 'confirmed', 'declined', 'cancelled', 'completed', 'needs_review') + ); + +ALTER TABLE orders + ADD COLUMN workflow_fulfillment TEXT CHECK ( + workflow_fulfillment IS NULL OR workflow_fulfillment IN ('confirmed', 'preparing', 'ready_for_pickup', 'out_for_delivery', 'delivered', 'cancelled') + ); + +ALTER TABLE orders + ADD COLUMN workflow_inventory TEXT NOT NULL DEFAULT 'needs_review' CHECK ( + workflow_inventory IN ('available', 'reserved', 'sold_out', 'needs_review') + ); + +ALTER TABLE orders + ADD COLUMN workflow_payment TEXT NOT NULL DEFAULT 'not_recorded' CHECK ( + workflow_payment IN ('not_recorded', 'recorded', 'needs_review') + ); + +ALTER TABLE orders + ADD COLUMN workflow_provenance_source TEXT NOT NULL DEFAULT 'unknown' CHECK ( + workflow_provenance_source IN ('app', 'cli', 'relay', 'local_events', 'unknown') + ); + +ALTER TABLE orders + ADD COLUMN workflow_provenance_last_event_id TEXT; + +UPDATE orders +SET + workflow_agreement = CASE status + WHEN 'scheduled' THEN 'confirmed' + WHEN 'packed' THEN 'confirmed' + WHEN 'completed' THEN 'completed' + WHEN 'declined' THEN 'declined' + WHEN 'refunded' THEN 'needs_review' + ELSE 'ordered' + END, + workflow_fulfillment = CASE status + WHEN 'scheduled' THEN 'confirmed' + WHEN 'packed' THEN 'ready_for_pickup' + WHEN 'completed' THEN 'delivered' + WHEN 'declined' THEN 'cancelled' + ELSE NULL + END, + workflow_inventory = CASE status + WHEN 'scheduled' THEN 'reserved' + WHEN 'packed' THEN 'reserved' + WHEN 'completed' THEN 'reserved' + WHEN 'declined' THEN 'available' + ELSE 'needs_review' + END, + workflow_payment = CASE status + WHEN 'refunded' THEN 'needs_review' + ELSE 'not_recorded' + END; diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -11,7 +11,7 @@ use radroots_events::{ kinds::{ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_RESPONSE, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, - KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, KIND_TRADE_RECEIPT, KIND_TRADE_SETTLEMENT_DECISION, }, trade::{ RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, @@ -23,7 +23,8 @@ use radroots_events_codec::trade::{ active_trade_fulfillment_update_from_event, active_trade_order_cancel_from_event, active_trade_order_decision_from_event, active_trade_order_request_from_event, active_trade_order_revision_decision_from_event, - active_trade_order_revision_proposal_from_event, + active_trade_order_revision_proposal_from_event, active_trade_payment_recorded_from_event, + active_trade_settlement_decision_from_event, }; use radroots_local_events::{ LocalEventRecord, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, @@ -32,9 +33,10 @@ use radroots_local_events::{ use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use radroots_trade::order::{ RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, - RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderProjection, - RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderRequestRecord, - RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord, + RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderPaymentRecord, + RadrootsActiveOrderProjection, RadrootsActiveOrderReceiptRecord, + RadrootsActiveOrderRequestRecord, RadrootsActiveOrderRevisionDecisionRecord, + RadrootsActiveOrderRevisionProposalRecord, RadrootsActiveOrderSettlementRecord, reduce_active_order_events, }; use rusqlite::{Connection, OptionalExtension, params}; @@ -56,7 +58,9 @@ const KIND_ORDER_REVISION_DECISION: i64 = KIND_TRADE_ORDER_REVISION_RESPONSE as const KIND_ORDER_CANCEL: i64 = KIND_TRADE_CANCEL as i64; const KIND_ORDER_FULFILLMENT: i64 = KIND_TRADE_FULFILLMENT_UPDATE as i64; const KIND_ORDER_RECEIPT: i64 = KIND_TRADE_RECEIPT as i64; -const ACTIVE_ORDER_EVENT_KINDS: [i64; 7] = [ +const KIND_ORDER_PAYMENT: i64 = KIND_TRADE_PAYMENT_RECORDED as i64; +const KIND_ORDER_SETTLEMENT: i64 = KIND_TRADE_SETTLEMENT_DECISION as i64; +const ACTIVE_ORDER_EVENT_KINDS: [i64; 9] = [ KIND_ORDER_REQUEST, KIND_ORDER_DECISION, KIND_ORDER_REVISION, @@ -64,6 +68,8 @@ const ACTIVE_ORDER_EVENT_KINDS: [i64; 7] = [ KIND_ORDER_CANCEL, KIND_ORDER_FULFILLMENT, KIND_ORDER_RECEIPT, + KIND_ORDER_PAYMENT, + KIND_ORDER_SETTLEMENT, ]; #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -1091,8 +1097,8 @@ impl<'a> AppLocalInteropRepository<'a> { buckets.fulfillments, buckets.cancellations, buckets.receipts, - [], - [], + buckets.payments, + buckets.settlements, ); let request_payload = projection.request_event_id.as_deref().and_then(|event_id| { requests @@ -1196,12 +1202,26 @@ impl<'a> AppLocalInteropRepository<'a> { "UPDATE orders SET status = ?2, workflow_revision = ?3, + workflow_agreement = ?4, + workflow_fulfillment = ?5, + workflow_inventory = ?6, + workflow_payment = ?7, + workflow_provenance_source = ?8, + workflow_provenance_last_event_id = ?9, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?1", params![ workflow.order_id.to_string(), status.storage_key(), - workflow.revision.storage_key() + workflow.revision.storage_key(), + workflow.agreement.storage_key(), + workflow + .fulfillment + .map(|fulfillment| fulfillment.storage_key()), + workflow.inventory.storage_key(), + workflow.payment.storage_key(), + workflow.provenance.primary_source.storage_key(), + workflow.provenance.last_event_id.as_deref() ], ) .map_err(|source| AppSqliteError::Query { @@ -2456,6 +2476,8 @@ enum ActiveOrderEvidence { Fulfillment(RadrootsActiveOrderFulfillmentRecord), Cancellation(RadrootsActiveOrderCancellationRecord), Receipt(RadrootsActiveOrderReceiptRecord), + Payment(RadrootsActiveOrderPaymentRecord), + Settlement(RadrootsActiveOrderSettlementRecord), } impl ActiveOrderEvidence { @@ -2468,6 +2490,8 @@ impl ActiveOrderEvidence { Self::Fulfillment(record) => record.event_id.as_str(), Self::Cancellation(record) => record.event_id.as_str(), Self::Receipt(record) => record.event_id.as_str(), + Self::Payment(record) => record.event_id.as_str(), + Self::Settlement(record) => record.event_id.as_str(), } } @@ -2480,6 +2504,8 @@ impl ActiveOrderEvidence { Self::Fulfillment(record) => record.payload.order_id.as_str(), Self::Cancellation(record) => record.payload.order_id.as_str(), Self::Receipt(record) => record.payload.order_id.as_str(), + Self::Payment(record) => record.payload.order_id.as_str(), + Self::Settlement(record) => record.payload.order_id.as_str(), } } @@ -2491,7 +2517,9 @@ impl ActiveOrderEvidence { | Self::RevisionDecision(_) | Self::Fulfillment(_) | Self::Cancellation(_) - | Self::Receipt(_) => None, + | Self::Receipt(_) + | Self::Payment(_) + | Self::Settlement(_) => None, } } @@ -2525,6 +2553,14 @@ impl ActiveOrderEvidence { record.payload.order_id.as_str(), record.payload.buyer_pubkey.as_str(), ), + Self::Payment(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), + Self::Settlement(record) => ( + record.payload.order_id.as_str(), + record.payload.buyer_pubkey.as_str(), + ), } } } @@ -2538,6 +2574,8 @@ struct ActiveOrderEvidenceBuckets { fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>, cancellations: Vec<RadrootsActiveOrderCancellationRecord>, receipts: Vec<RadrootsActiveOrderReceiptRecord>, + payments: Vec<RadrootsActiveOrderPaymentRecord>, + settlements: Vec<RadrootsActiveOrderSettlementRecord>, } impl ActiveOrderEvidenceBuckets { @@ -2556,6 +2594,8 @@ impl ActiveOrderEvidenceBuckets { ActiveOrderEvidence::Fulfillment(record) => buckets.fulfillments.push(record), ActiveOrderEvidence::Cancellation(record) => buckets.cancellations.push(record), ActiveOrderEvidence::Receipt(record) => buckets.receipts.push(record), + ActiveOrderEvidence::Payment(record) => buckets.payments.push(record), + ActiveOrderEvidence::Settlement(record) => buckets.settlements.push(record), } } buckets @@ -2812,6 +2852,36 @@ fn active_order_evidence_from_event(event: &RadrootsNostrEvent) -> Option<Active }, )) } + KIND_ORDER_PAYMENT => { + let envelope = active_trade_payment_recorded_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::Payment( + RadrootsActiveOrderPaymentRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } + KIND_ORDER_SETTLEMENT => { + let envelope = active_trade_settlement_decision_from_event(event).ok()?; + let context = + active_trade_event_context_from_tags(envelope.message_type, &event.tags).ok()?; + Some(ActiveOrderEvidence::Settlement( + RadrootsActiveOrderSettlementRecord { + event_id: event.id.clone(), + author_pubkey: event.author.clone(), + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id?, + prev_event_id: context.prev_event_id?, + payload: envelope.payload, + }, + )) + } _ => None, } } @@ -3562,7 +3632,8 @@ mod tests { use radroots_app_view::{ BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderStatus, OrdersFilter, - OrdersScreenQueryState, ProductAvailabilityState, ProductId, TradeRevisionStatus, + OrdersScreenQueryState, ProductAvailabilityState, ProductId, TradePaymentDisplayStatus, + TradeRevisionStatus, TradeWorkflowSource, }; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, @@ -3577,7 +3648,7 @@ mod tests { RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, - RadrootsTradePricingBasis, + RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, }, }; use radroots_events_codec::{ @@ -3587,6 +3658,7 @@ mod tests { active_trade_order_request_event_build, active_trade_order_revision_decision_event_build, active_trade_order_revision_proposal_event_build, + active_trade_payment_recorded_event_build, }, wire::WireEventParts, }; @@ -3595,6 +3667,7 @@ mod tests { LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, }; use radroots_sql_core::SqliteExecutor; + use radroots_trade::order::radroots_trade_order_economics_digest; use rusqlite::params; use serde_json::json; use uuid::Uuid; @@ -4242,6 +4315,32 @@ mod tests { } } + fn payment_recorded_payload( + request: &RadrootsTradeOrderRequested, + root_event_id: &str, + previous_event_id: &str, + agreement_event_id: &str, + ) -> RadrootsTradePaymentRecorded { + RadrootsTradePaymentRecorded { + order_id: request.order_id.clone(), + listing_addr: request.listing_addr.clone(), + buyer_pubkey: request.buyer_pubkey.clone(), + seller_pubkey: request.seller_pubkey.clone(), + root_event_id: root_event_id.to_owned(), + previous_event_id: previous_event_id.to_owned(), + agreement_event_id: agreement_event_id.to_owned(), + quote_id: request.economics.quote_id.clone(), + quote_version: request.economics.quote_version, + economics_digest: radroots_trade_order_economics_digest(&request.economics) + .expect("order economics digest should encode"), + amount: request.economics.total.amount, + currency: request.economics.total.currency, + method: RadrootsTradePaymentMethod::ManualTransfer, + reference: Some("manual reference".to_owned()), + paid_at: Some(1_777_665_800), + } + } + fn event_from_parts(event_id: &str, author: &str, parts: WireEventParts) -> RadrootsNostrEvent { RadrootsNostrEvent { id: event_id.to_owned(), @@ -4671,8 +4770,68 @@ mod tests { assert_eq!(decision_report.imported_records, 1); assert_eq!(buyer_orders.rows.len(), 1); assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Scheduled); + assert_eq!( + buyer_orders.rows[0].workflow.payment, + TradePaymentDisplayStatus::NotRecorded + ); assert_eq!(buyer_detail.status, BuyerOrderStatus::Scheduled); assert_eq!(seller_orders.rows[0].status, OrderStatus::Scheduled); + + let payment_payload = payment_recorded_payload( + &request_payload, + request_event.id.as_str(), + decision_event.id.as_str(), + decision_event.id.as_str(), + ); + let payment_parts = active_trade_payment_recorded_event_build( + request_event.id.as_str(), + decision_event.id.as_str(), + &payment_payload, + ) + .expect("build payment recorded event"); + let payment_event = + event_from_parts("buyer-order-payment-event", buyer_pubkey, payment_parts); + events + .append_record(&signed_order_event_record( + "app:signed_event:order-payment:buyer", + &payment_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_buyer"), + )) + .expect("append payment recorded event"); + + let payment_report = app_store + .import_shared_local_events_from_store(&events) + .expect("import payment recorded event"); + let buyer_orders = app_store + .load_buyer_orders(&buyer_context) + .expect("load buyer orders after payment"); + let buyer_detail = app_store + .load_buyer_order_detail(&buyer_context, order_id) + .expect("load buyer order detail after payment") + .expect("buyer order detail after payment"); + + assert_eq!(payment_report.imported_records, 1); + assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Scheduled); + assert_eq!( + buyer_orders.rows[0].workflow.payment, + TradePaymentDisplayStatus::Recorded + ); + assert_eq!( + buyer_orders.rows[0].workflow.provenance.primary_source, + TradeWorkflowSource::LocalEvents + ); + assert_eq!( + buyer_orders.rows[0] + .workflow + .provenance + .last_event_id + .as_deref(), + Some(decision_event.id.as_str()) + ); + assert_eq!(buyer_detail.payment, TradePaymentDisplayStatus::Recorded); + assert_eq!(buyer_detail.workflow, buyer_orders.rows[0].workflow); } #[test] @@ -6367,16 +6526,16 @@ mod tests { ) .expect("buyer cart should save"); app_store - .save_buyer_checkout_draft( + .save_buyer_order_review_draft( &buyer_context, - &radroots_app_view::BuyerCheckoutDraft { + &radroots_app_view::BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.test".to_owned(), phone: String::new(), order_note: String::new(), }, ) - .expect("checkout draft should save"); + .expect("order review draft should save"); let order_id = app_store .place_buyer_order(&buyer_context) .expect("buyer order should place"); diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs @@ -14,8 +14,8 @@ use radroots_app_sync::{ }; use radroots_app_view::{ AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind, - BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerContext, - BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrdersProjection, + BuyerCartProjection, BuyerContext, BuyerListingsProjection, BuyerOrderDetailProjection, + BuyerOrderReviewDraft, BuyerOrderReviewProjection, BuyerOrdersProjection, BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmRulesProjection, FarmSetupProjection, FarmSummary, FulfillmentWindowId, OrderDetailProjection, OrderId, OrderRecoveryProjection, OrdersListProjection, OrdersScreenQueryState, PackDayOutputSource, @@ -421,20 +421,20 @@ impl AppSqliteStore { self.buyer_repository().clear_buyer_cart(context) } - pub fn load_buyer_checkout( + pub fn load_buyer_order_review( &self, context: &BuyerContext, - ) -> Result<BuyerCheckoutProjection, AppSqliteError> { - self.buyer_repository().load_buyer_checkout(context) + ) -> Result<BuyerOrderReviewProjection, AppSqliteError> { + self.buyer_repository().load_buyer_order_review(context) } - pub fn save_buyer_checkout_draft( + pub fn save_buyer_order_review_draft( &self, context: &BuyerContext, - draft: &BuyerCheckoutDraft, + draft: &BuyerOrderReviewDraft, ) -> Result<(), AppSqliteError> { self.buyer_repository() - .save_buyer_checkout_draft(context, draft) + .save_buyer_order_review_draft(context, draft) } pub fn place_buyer_order(&self, context: &BuyerContext) -> Result<OrderId, AppSqliteError> { diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs @@ -92,6 +92,10 @@ const MIGRATIONS: &[Migration] = &[ version: 22, sql: include_str!("../migrations/0022_order_workflow_revision.sql"), }, + Migration { + version: 23, + sql: include_str!("../migrations/0023_order_workflow_display_projection.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -2,14 +2,16 @@ use std::collections::{BTreeMap, BTreeSet}; use radroots_app_view::{ BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, - BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerCheckoutProjection, - BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection, - BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection, + BuyerContext, BuyerListingRow, BuyerListingsProjection, BuyerOrderDetailProjection, + BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft, BuyerOrderReviewProjection, + BuyerOrderReviewSummaryProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection, BuyerProductDetailProjection, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow, OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId, ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary, - RepeatDemandEligibility, RepeatDemandHandoffProjection, TradePaymentDisplayStatus, - TradeWorkflowProjection, + RepeatDemandEligibility, RepeatDemandHandoffProjection, TradeAgreementStatus, + TradeEconomicsProjection, TradeFulfillmentStatus, TradeInventoryStatus, + TradePaymentDisplayStatus, TradeProvenanceProjection, TradeRevisionStatus, + TradeWorkflowProjection, TradeWorkflowSource, }; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; @@ -270,24 +272,28 @@ impl<'a> AppBuyerRepository<'a> { Ok(()) } - pub fn load_buyer_checkout( + pub fn load_buyer_order_review( &self, context: &BuyerContext, - ) -> Result<BuyerCheckoutProjection, AppSqliteError> { + ) -> Result<BuyerOrderReviewProjection, AppSqliteError> { let context_key = context.storage_key(); let header = self.load_cart_header(&context_key)?; let cart = self.build_cart_projection(header.clone(), self.load_cart_line_records(&context_key)?)?; let draft = header - .map(BuyerCartHeader::into_checkout_draft) + .map(BuyerCartHeader::into_order_review_draft) .unwrap_or_default(); let fulfillment_summary = shared_fulfillment_summary(&cart.lines); - let place_order_disabled_reason = - buyer_checkout_disabled_reason(context, &cart, fulfillment_summary.as_ref(), &draft); + let place_order_disabled_reason = buyer_order_review_disabled_reason( + context, + &cart, + fulfillment_summary.as_ref(), + &draft, + ); - Ok(BuyerCheckoutProjection { + Ok(BuyerOrderReviewProjection { draft: draft.clone(), - summary: BuyerCheckoutSummaryProjection { + summary: BuyerOrderReviewSummaryProjection { farm_display_name: cart.farm_display_name.clone(), fulfillment_summary: fulfillment_summary.clone(), line_count: cart.lines.len() as u32, @@ -299,10 +305,10 @@ impl<'a> AppBuyerRepository<'a> { }) } - pub fn save_buyer_checkout_draft( + pub fn save_buyer_order_review_draft( &self, context: &BuyerContext, - draft: &BuyerCheckoutDraft, + draft: &BuyerOrderReviewDraft, ) -> Result<(), AppSqliteError> { let context_key = context.storage_key(); @@ -317,7 +323,7 @@ impl<'a> AppBuyerRepository<'a> { params![context_key.as_str()], ) .map_err(|source| AppSqliteError::Query { - operation: "ensure buyer checkout header", + operation: "ensure buyer order review header", source, })?; self.connection @@ -339,7 +345,7 @@ impl<'a> AppBuyerRepository<'a> { ], ) .map_err(|source| AppSqliteError::Query { - operation: "save buyer checkout draft", + operation: "save buyer order review draft", source, })?; @@ -355,11 +361,11 @@ impl<'a> AppBuyerRepository<'a> { })?; let line_records = self.load_cart_line_records(&context_key)?; let cart = self.build_cart_projection(Some(header.clone()), line_records.clone())?; - let checkout = self.load_buyer_checkout(context)?; + let order_review = self.load_buyer_order_review(context)?; - if let Some(disabled_reason) = checkout.place_order_disabled_reason { + if let Some(disabled_reason) = order_review.place_order_disabled_reason { return Err(AppSqliteError::InvalidProjection { - reason: buyer_checkout_disabled_error(disabled_reason), + reason: buyer_order_review_disabled_error(disabled_reason), }); } @@ -373,7 +379,7 @@ impl<'a> AppBuyerRepository<'a> { self.connection .execute_batch("begin immediate transaction") .map_err(|source| AppSqliteError::Query { - operation: "begin buyer checkout write", + operation: "begin buyer order review write", source, })?; @@ -410,11 +416,11 @@ impl<'a> AppBuyerRepository<'a> { farm_id.to_string(), fulfillment_window_id.map(|id| id.to_string()), order_number, - checkout.draft.name.trim(), + order_review.draft.name.trim(), context_key.as_str(), - checkout.draft.email.trim(), - checkout.draft.phone.trim(), - checkout.draft.order_note.trim(), + order_review.draft.email.trim(), + order_review.draft.phone.trim(), + order_review.draft.order_note.trim(), ], ) .map_err(|source| AppSqliteError::Query { @@ -473,7 +479,7 @@ impl<'a> AppBuyerRepository<'a> { params![context_key.as_str()], ) .map_err(|source| AppSqliteError::Query { - operation: "clear buyer cart lines after checkout", + operation: "clear buyer cart lines after order review", source, })?; self.connection @@ -487,7 +493,7 @@ impl<'a> AppBuyerRepository<'a> { params![context_key.as_str()], ) .map_err(|source| AppSqliteError::Query { - operation: "reset buyer cart header after checkout", + operation: "reset buyer cart header after order review", source, })?; self.insert_pending_buyer_order_coordination(context_key.as_str(), order_id)?; @@ -499,7 +505,7 @@ impl<'a> AppBuyerRepository<'a> { Ok(order_id) => { self.connection.execute_batch("commit").map_err(|source| { AppSqliteError::Query { - operation: "commit buyer checkout write", + operation: "commit buyer order review write", source, } })?; @@ -744,6 +750,12 @@ impl<'a> AppBuyerRepository<'a> { o.order_number, o.status, o.workflow_revision, + o.workflow_agreement, + o.workflow_fulfillment, + o.workflow_inventory, + o.workflow_payment, + o.workflow_provenance_source, + o.workflow_provenance_last_event_id, f.display_name, fw.label, fw.starts_at, @@ -768,8 +780,14 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, String>(4)?, row.get::<_, String>(5)?, row.get::<_, Option<String>>(6)?, - row.get::<_, Option<String>>(7)?, - row.get::<_, Option<String>>(8)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, Option<String>>(10)?, + row.get::<_, String>(11)?, + row.get::<_, Option<String>>(12)?, + row.get::<_, Option<String>>(13)?, + row.get::<_, Option<String>>(14)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -785,6 +803,12 @@ impl<'a> AppBuyerRepository<'a> { order_number, status, workflow_revision, + workflow_agreement, + workflow_fulfillment, + workflow_inventory, + workflow_payment, + workflow_provenance_source, + workflow_provenance_last_event_id, farm_display_name, fulfillment_label, fulfillment_starts_at, @@ -793,11 +817,24 @@ impl<'a> AppBuyerRepository<'a> { operation: "read buyer orders list", source, })?; - let order_id = parse_typed_id("orders.id", order_id)?; - let farm_id = parse_typed_id("orders.farm_id", farm_id)?; + let order_id: OrderId = parse_typed_id("orders.id", order_id)?; + let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; let buyer_status = BuyerOrderStatus::from(parse_order_status("orders.status", status)?); let revision = parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; + let items = self.load_order_detail_items(order_id.to_string())?; + let economics = order_detail_economics(&items)?; + let workflow = trade_workflow_projection_from_storage( + order_id, + revision, + economics, + workflow_agreement, + workflow_fulfillment, + workflow_inventory, + workflow_payment, + workflow_provenance_source, + workflow_provenance_last_event_id, + )?; orders.push(BuyerOrdersListRow { order_id, @@ -816,8 +853,7 @@ impl<'a> AppBuyerRepository<'a> { fulfillment_ends_at, ), status: buyer_status, - workflow: TradeWorkflowProjection::from_buyer_order_status(order_id, buyer_status) - .with_revision(revision), + workflow, }); } @@ -842,6 +878,12 @@ impl<'a> AppBuyerRepository<'a> { o.status, o.buyer_order_note, o.workflow_revision, + o.workflow_agreement, + o.workflow_fulfillment, + o.workflow_inventory, + o.workflow_payment, + o.workflow_provenance_source, + o.workflow_provenance_last_event_id, f.display_name, fw.label, fw.starts_at, @@ -862,8 +904,14 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, String>(5)?, row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, - row.get::<_, Option<String>>(8)?, - row.get::<_, Option<String>>(9)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + row.get::<_, Option<String>>(11)?, + row.get::<_, String>(12)?, + row.get::<_, Option<String>>(13)?, + row.get::<_, Option<String>>(14)?, + row.get::<_, Option<String>>(15)?, )) }, ) @@ -882,6 +930,12 @@ impl<'a> AppBuyerRepository<'a> { status, order_note, workflow_revision, + workflow_agreement, + workflow_fulfillment, + workflow_inventory, + workflow_payment, + workflow_provenance_source, + workflow_provenance_last_event_id, farm_display_name, fulfillment_label, fulfillment_starts_at, @@ -895,11 +949,18 @@ impl<'a> AppBuyerRepository<'a> { parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; let items = self.load_order_detail_items(order_id.to_string())?; let economics = order_detail_economics(&items)?; - let payment = TradePaymentDisplayStatus::NotRecorded; - let workflow = - TradeWorkflowProjection::from_buyer_order_status(order_id, status) - .with_revision(revision) - .with_economics_and_payment(economics.clone(), payment); + let workflow = trade_workflow_projection_from_storage( + order_id, + revision, + economics.clone(), + workflow_agreement, + workflow_fulfillment, + workflow_inventory, + workflow_payment, + workflow_provenance_source, + workflow_provenance_last_event_id, + )?; + let payment = workflow.payment; Ok(BuyerOrderDetailProjection { order_id, farm_id, @@ -2064,8 +2125,8 @@ struct BuyerCartHeader { } impl BuyerCartHeader { - fn into_checkout_draft(self) -> BuyerCheckoutDraft { - BuyerCheckoutDraft { + fn into_order_review_draft(self) -> BuyerOrderReviewDraft { + BuyerOrderReviewDraft { name: self.buyer_name, email: self.buyer_email, phone: self.buyer_phone, @@ -2474,6 +2535,104 @@ fn refresh_buyer_cart_summary(cart: &mut BuyerCartProjection) -> Result<(), AppS Ok(()) } +fn trade_workflow_projection_from_storage( + order_id: OrderId, + revision: TradeRevisionStatus, + economics: TradeEconomicsProjection, + agreement: String, + fulfillment: Option<String>, + inventory: String, + payment: String, + provenance_source: String, + provenance_last_event_id: Option<String>, +) -> Result<TradeWorkflowProjection, AppSqliteError> { + Ok(TradeWorkflowProjection { + order_id, + agreement: parse_trade_agreement_status("orders.workflow_agreement", agreement)?, + revision, + fulfillment: fulfillment + .map(|value| parse_trade_fulfillment_status("orders.workflow_fulfillment", value)) + .transpose()?, + economics, + inventory: parse_trade_inventory_status("orders.workflow_inventory", inventory)?, + payment: parse_trade_payment_display_status("orders.workflow_payment", payment)?, + provenance: TradeProvenanceProjection::from_primary_source(parse_trade_workflow_source( + "orders.workflow_provenance_source", + provenance_source, + )?) + .with_last_event_id(provenance_last_event_id), + }) +} + +fn parse_trade_agreement_status( + field: &'static str, + value: String, +) -> Result<TradeAgreementStatus, AppSqliteError> { + match value.as_str() { + "ordered" => Ok(TradeAgreementStatus::Ordered), + "confirmed" => Ok(TradeAgreementStatus::Confirmed), + "declined" => Ok(TradeAgreementStatus::Declined), + "cancelled" => Ok(TradeAgreementStatus::Cancelled), + "completed" => Ok(TradeAgreementStatus::Completed), + "needs_review" => Ok(TradeAgreementStatus::NeedsReview), + _ => Err(AppSqliteError::DecodeEnum { field, value }), + } +} + +fn parse_trade_fulfillment_status( + field: &'static str, + value: String, +) -> Result<TradeFulfillmentStatus, AppSqliteError> { + match value.as_str() { + "confirmed" => Ok(TradeFulfillmentStatus::Confirmed), + "preparing" => Ok(TradeFulfillmentStatus::Preparing), + "ready_for_pickup" => Ok(TradeFulfillmentStatus::ReadyForPickup), + "out_for_delivery" => Ok(TradeFulfillmentStatus::OutForDelivery), + "delivered" => Ok(TradeFulfillmentStatus::Delivered), + "cancelled" => Ok(TradeFulfillmentStatus::Cancelled), + _ => Err(AppSqliteError::DecodeEnum { field, value }), + } +} + +fn parse_trade_inventory_status( + field: &'static str, + value: String, +) -> Result<TradeInventoryStatus, AppSqliteError> { + match value.as_str() { + "available" => Ok(TradeInventoryStatus::Available), + "reserved" => Ok(TradeInventoryStatus::Reserved), + "sold_out" => Ok(TradeInventoryStatus::SoldOut), + "needs_review" => Ok(TradeInventoryStatus::NeedsReview), + _ => Err(AppSqliteError::DecodeEnum { field, value }), + } +} + +fn parse_trade_payment_display_status( + field: &'static str, + value: String, +) -> Result<TradePaymentDisplayStatus, AppSqliteError> { + match value.as_str() { + "not_recorded" => Ok(TradePaymentDisplayStatus::NotRecorded), + "recorded" => Ok(TradePaymentDisplayStatus::Recorded), + "needs_review" => Ok(TradePaymentDisplayStatus::NeedsReview), + _ => Err(AppSqliteError::DecodeEnum { field, value }), + } +} + +fn parse_trade_workflow_source( + field: &'static str, + value: String, +) -> Result<TradeWorkflowSource, AppSqliteError> { + match value.as_str() { + "app" => Ok(TradeWorkflowSource::App), + "cli" => Ok(TradeWorkflowSource::Cli), + "relay" => Ok(TradeWorkflowSource::Relay), + "local_events" => Ok(TradeWorkflowSource::LocalEvents), + "unknown" => Ok(TradeWorkflowSource::Unknown), + _ => Err(AppSqliteError::DecodeEnum { field, value }), + } +} + fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<String> { let first = lines.first()?.fulfillment_summary.clone(); @@ -2483,40 +2642,40 @@ fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<Strin .then_some(first) } -fn buyer_checkout_disabled_reason( +fn buyer_order_review_disabled_reason( context: &BuyerContext, cart: &BuyerCartProjection, fulfillment_summary: Option<&String>, - draft: &BuyerCheckoutDraft, -) -> Option<BuyerCheckoutDisabledReason> { + draft: &BuyerOrderReviewDraft, +) -> Option<BuyerOrderReviewDisabledReason> { if cart.lines.is_empty() { - return Some(BuyerCheckoutDisabledReason::EmptyCart); + return Some(BuyerOrderReviewDisabledReason::EmptyCart); } if fulfillment_summary.is_none() { - return Some(BuyerCheckoutDisabledReason::MissingFulfillment); + return Some(BuyerOrderReviewDisabledReason::MissingFulfillment); } if draft.name.trim().is_empty() { - return Some(BuyerCheckoutDisabledReason::MissingName); + return Some(BuyerOrderReviewDisabledReason::MissingName); } if draft.email.trim().is_empty() { - return Some(BuyerCheckoutDisabledReason::MissingEmail); + return Some(BuyerOrderReviewDisabledReason::MissingEmail); } if matches!(context, BuyerContext::Guest) { - return Some(BuyerCheckoutDisabledReason::AccountRequired); + return Some(BuyerOrderReviewDisabledReason::AccountRequired); } None } -fn buyer_checkout_disabled_error(reason: BuyerCheckoutDisabledReason) -> &'static str { +fn buyer_order_review_disabled_error(reason: BuyerOrderReviewDisabledReason) -> &'static str { match reason { - BuyerCheckoutDisabledReason::EmptyCart => "buyer checkout cart is empty", - BuyerCheckoutDisabledReason::MissingFulfillment => { - "buyer checkout fulfillment is unavailable" + BuyerOrderReviewDisabledReason::EmptyCart => "buyer order review cart is empty", + BuyerOrderReviewDisabledReason::MissingFulfillment => { + "buyer order review fulfillment is unavailable" } - BuyerCheckoutDisabledReason::MissingName => "buyer checkout buyer name is missing", - BuyerCheckoutDisabledReason::MissingEmail => "buyer checkout buyer email is missing", - BuyerCheckoutDisabledReason::AccountRequired => { - "buyer checkout requires a selected account" + BuyerOrderReviewDisabledReason::MissingName => "buyer order review buyer name is missing", + BuyerOrderReviewDisabledReason::MissingEmail => "buyer order review buyer email is missing", + BuyerOrderReviewDisabledReason::AccountRequired => { + "buyer order review requires a selected account" } } } @@ -2538,7 +2697,7 @@ fn shared_fulfillment_window_id( Ok(first_window_id) } else { Err(AppSqliteError::InvalidProjection { - reason: "buyer cart must share one fulfillment window at checkout", + reason: "buyer cart must share one fulfillment window at order review", }) } } @@ -2795,8 +2954,9 @@ mod tests { use std::collections::BTreeSet; use radroots_app_view::{ - BuyerCheckoutDisabledReason, BuyerContext, FarmId, FarmOrderMethod, FulfillmentWindowId, - OrderId, PickupLocationId, ProductId, TradePaymentDisplayStatus, TradeRevisionStatus, + BuyerContext, BuyerOrderReviewDisabledReason, FarmId, FarmOrderMethod, FulfillmentWindowId, + OrderId, PickupLocationId, ProductId, TradeAgreementStatus, TradeFulfillmentStatus, + TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowSource, }; use rusqlite::{Connection, params}; use serde_json::json; @@ -2942,7 +3102,7 @@ mod tests { } #[test] - fn buyer_checkout_requires_account_before_order_write() { + fn buyer_order_review_requires_account_before_order_write() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let connection = store.connection(); let repository = AppBuyerRepository::new(connection); @@ -3002,36 +3162,36 @@ mod tests { ) .expect("buyer cart should save"); repository - .save_buyer_checkout_draft( + .save_buyer_order_review_draft( &context, - &radroots_app_view::BuyerCheckoutDraft { + &radroots_app_view::BuyerOrderReviewDraft { 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"); + .expect("buyer order review draft should save"); - let checkout = repository - .load_buyer_checkout(&context) - .expect("buyer checkout should load"); + let order_review = repository + .load_buyer_order_review(&context) + .expect("buyer order review should load"); let error = repository .place_buyer_order(&context) - .expect_err("guest checkout should require an account"); - let cart_after_checkout = repository + .expect_err("guest order review should require an account"); + let cart_after_order_review = repository .load_buyer_cart(&context) - .expect("buyer cart should remain after blocked checkout"); + .expect("buyer cart should remain after blocked order review"); assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); - assert!(!checkout.can_place_order); + assert!(!order_review.can_place_order); assert_eq!( - checkout.place_order_disabled_reason, - Some(BuyerCheckoutDisabledReason::AccountRequired) + order_review.place_order_disabled_reason, + Some(BuyerOrderReviewDisabledReason::AccountRequired) ); - assert_eq!(checkout.summary.line_count, 1); - assert_eq!(cart_after_checkout.lines.len(), 1); - assert_eq!(cart_after_checkout.farm_id, Some(farm_id)); + assert_eq!(order_review.summary.line_count, 1); + assert_eq!(cart_after_order_review.lines.len(), 1); + assert_eq!(cart_after_order_review.farm_id, Some(farm_id)); assert_eq!(row_count(connection, "orders"), 0); assert_eq!(row_count(connection, "order_lines"), 0); assert_eq!(row_count(connection, "buyer_order_coordination_records"), 0); @@ -3132,19 +3292,19 @@ mod tests { ) .expect("buyer cart should save"); repository - .save_buyer_checkout_draft( + .save_buyer_order_review_draft( &context, - &radroots_app_view::BuyerCheckoutDraft { + &radroots_app_view::BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), order_note: String::new(), }, ) - .expect("buyer checkout draft should save"); + .expect("buyer order review draft should save"); let order_id = repository .place_buyer_order(&context) - .expect("buyer checkout should place order"); + .expect("buyer order review should place order"); connection .execute( @@ -3262,19 +3422,19 @@ mod tests { ) .expect("buyer cart should save"); repository - .save_buyer_checkout_draft( + .save_buyer_order_review_draft( &context, - &radroots_app_view::BuyerCheckoutDraft { + &radroots_app_view::BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), order_note: String::new(), }, ) - .expect("buyer checkout draft should save"); + .expect("buyer order review draft should save"); let order_id = repository .place_buyer_order(&context) - .expect("buyer checkout should place order"); + .expect("buyer order review should place order"); connection .execute( @@ -3419,6 +3579,84 @@ mod tests { } #[test] + fn buyer_order_projections_read_workflow_display_snapshot() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let repository = AppBuyerRepository::new(connection); + let context = BuyerContext::account("acct_buyer"); + let farm_id = insert_farm(connection, "Willow Farm", "ready"); + let order_id = OrderId::new(); + let product_id = ProductId::new(); + + insert_order( + connection, + order_id, + farm_id, + "R-100", + "needs_action", + Some("account:acct_buyer"), + "buyer@example.com", + "", + "", + ); + insert_order_line( + connection, + order_id, + product_id, + "Salad mix", + 2, + "bag", + Some(650), + "USD", + ); + set_order_workflow_revision( + connection, + order_id, + TradeRevisionStatus::KeptAsPlaced.storage_key(), + ); + set_order_workflow_display_projection( + connection, + order_id, + "confirmed", + Some("ready_for_pickup"), + "reserved", + "recorded", + "local_events", + Some("payment-event-1"), + ); + + let list = repository + .load_buyer_orders(&context) + .expect("buyer order list should load"); + let detail = repository + .load_buyer_order_detail(&context, order_id) + .expect("buyer order detail should load") + .expect("buyer order detail should exist"); + let row = &list.rows[0]; + + assert_eq!(list.rows.len(), 1); + assert_eq!(row.workflow.agreement, TradeAgreementStatus::Confirmed); + assert_eq!( + row.workflow.fulfillment, + Some(TradeFulfillmentStatus::ReadyForPickup) + ); + assert_eq!(row.workflow.inventory, TradeInventoryStatus::Reserved); + assert_eq!(row.workflow.payment, TradePaymentDisplayStatus::Recorded); + assert_eq!( + row.workflow.provenance.primary_source, + TradeWorkflowSource::LocalEvents + ); + assert_eq!( + row.workflow.provenance.last_event_id.as_deref(), + Some("payment-event-1") + ); + assert_eq!(row.workflow.economics.total_minor_units, Some(1300)); + assert_eq!(row.workflow.economics.currency_code.as_deref(), Some("USD")); + assert_eq!(detail.workflow, row.workflow); + assert_eq!(detail.payment, TradePaymentDisplayStatus::Recorded); + } + + #[test] fn buyer_cart_rejects_cross_farm_lines() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let farm_id = FarmId::new(); @@ -3684,6 +3922,76 @@ mod tests { .expect("order workflow revision update should succeed"); } + fn set_order_workflow_display_projection( + connection: &Connection, + order_id: OrderId, + agreement: &str, + fulfillment: Option<&str>, + inventory: &str, + payment: &str, + provenance_source: &str, + provenance_last_event_id: Option<&str>, + ) { + connection + .execute( + "update orders + set workflow_agreement = ?1, + workflow_fulfillment = ?2, + workflow_inventory = ?3, + workflow_payment = ?4, + workflow_provenance_source = ?5, + workflow_provenance_last_event_id = ?6 + where id = ?7", + params![ + agreement, + fulfillment, + inventory, + payment, + provenance_source, + provenance_last_event_id, + order_id.to_string(), + ], + ) + .expect("order workflow display projection update should succeed"); + } + + fn insert_order_line( + connection: &Connection, + order_id: OrderId, + product_id: ProductId, + title: &str, + quantity: i64, + unit_label: &str, + unit_price_minor_units: Option<u32>, + price_currency: &str, + ) { + connection + .execute( + "insert into order_lines ( + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + unit_price_minor_units, + price_currency, + sort_index + ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)", + params![ + format!("{order_id}:{product_id}"), + order_id.to_string(), + title, + quantity, + unit_label, + format!("{quantity} {unit_label}"), + unit_price_minor_units, + price_currency, + ], + ) + .expect("order line insert should succeed"); + } + fn corrupt_order_workflow_revision( connection: &Connection, order_id: OrderId, diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -1017,7 +1017,7 @@ impl BuyerCartProjection { } #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCheckoutDraft { +pub struct BuyerOrderReviewDraft { pub name: String, pub email: String, pub phone: String, @@ -1025,7 +1025,7 @@ pub struct BuyerCheckoutDraft { } #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCheckoutSummaryProjection { +pub struct BuyerOrderReviewSummaryProjection { pub farm_display_name: Option<String>, pub fulfillment_summary: Option<String>, pub line_count: u32, @@ -1035,7 +1035,7 @@ pub struct BuyerCheckoutSummaryProjection { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum BuyerCheckoutDisabledReason { +pub enum BuyerOrderReviewDisabledReason { EmptyCart, MissingFulfillment, MissingName, @@ -1043,7 +1043,7 @@ pub enum BuyerCheckoutDisabledReason { AccountRequired, } -impl BuyerCheckoutDisabledReason { +impl BuyerOrderReviewDisabledReason { pub const fn storage_key(self) -> &'static str { match self { Self::EmptyCart => "empty_cart", @@ -1056,11 +1056,11 @@ impl BuyerCheckoutDisabledReason { } #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCheckoutProjection { - pub draft: BuyerCheckoutDraft, - pub summary: BuyerCheckoutSummaryProjection, +pub struct BuyerOrderReviewProjection { + pub draft: BuyerOrderReviewDraft, + pub summary: BuyerOrderReviewSummaryProjection, pub can_place_order: bool, - pub place_order_disabled_reason: Option<BuyerCheckoutDisabledReason>, + pub place_order_disabled_reason: Option<BuyerOrderReviewDisabledReason>, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] @@ -2189,19 +2189,20 @@ mod tests { AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection, - BuyerCartProjection, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, - BuyerCheckoutProjection, BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, - BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, - BuyerOrdersProjection, FarmId, FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, - FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, - FarmSetupReadiness, FarmSetupSection, FarmTimingConflict, FarmTimingConflictKind, - FarmerActivationProjection, FarmerSection, FulfillmentWindowId, IdentityBlockedReason, - IdentityReadiness, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, - OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, - OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, - OrdersScreenQueryState, PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, - PackDayBatchPrintStatus, PackDayExportArtifact, PackDayExportArtifactKind, - PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, + BuyerCartProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection, + BuyerOrderDetailProjection, BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft, + BuyerOrderReviewProjection, BuyerOrderReviewSummaryProjection, BuyerOrderStatus, + BuyerOrdersListRow, BuyerOrdersProjection, FarmId, FarmOrderMethod, FarmReadinessBlocker, + FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, + FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, FarmTimingConflict, + FarmTimingConflictKind, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, + IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase, + LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, + OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, OrderStatus, OrdersFilter, + OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, + PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, + PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayPrintFailureKind, @@ -2677,25 +2678,25 @@ mod tests { } #[test] - fn buyer_checkout_disabled_reason_storage_keys_are_stable() { + fn buyer_order_review_disabled_reason_storage_keys_are_stable() { assert_eq!( - BuyerCheckoutDisabledReason::EmptyCart.storage_key(), + BuyerOrderReviewDisabledReason::EmptyCart.storage_key(), "empty_cart" ); assert_eq!( - BuyerCheckoutDisabledReason::MissingFulfillment.storage_key(), + BuyerOrderReviewDisabledReason::MissingFulfillment.storage_key(), "missing_fulfillment" ); assert_eq!( - BuyerCheckoutDisabledReason::MissingName.storage_key(), + BuyerOrderReviewDisabledReason::MissingName.storage_key(), "missing_name" ); assert_eq!( - BuyerCheckoutDisabledReason::MissingEmail.storage_key(), + BuyerOrderReviewDisabledReason::MissingEmail.storage_key(), "missing_email" ); assert_eq!( - BuyerCheckoutDisabledReason::AccountRequired.storage_key(), + BuyerOrderReviewDisabledReason::AccountRequired.storage_key(), "account_required" ); } @@ -3615,14 +3616,14 @@ mod tests { currency_code: Some("USD".to_owned()), replace_confirmation: None, }; - let checkout = BuyerCheckoutProjection { - draft: BuyerCheckoutDraft { + let order_review = BuyerOrderReviewProjection { + draft: BuyerOrderReviewDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), order_note: "Leave by the cooler".to_owned(), }, - summary: BuyerCheckoutSummaryProjection { + summary: BuyerOrderReviewSummaryProjection { farm_display_name: Some("Cedar Grove Farm".to_owned()), fulfillment_summary: Some("Thursday pickup".to_owned()), line_count: 1, @@ -3677,7 +3678,7 @@ mod tests { assert!(!listings.is_empty()); assert!(!cart.is_empty()); - assert!(checkout.can_place_order); + assert!(order_review.can_place_order); assert!(!orders.is_empty()); assert_eq!(listing.fulfillment_methods.len(), 1); assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -143,7 +143,7 @@ "personal.order_summary.title": "Order summary", "personal.fulfillment.title": "Fulfillment", "personal.cart.remove_line.action": "Remove", - "personal.cart.continue_checkout.action": "Review order", + "personal.cart.review_order.action": "Review order", "personal.cart.line.quantity.label": "Quantity", "personal.cart.line.unit_price.label": "Unit price", "personal.cart.line.total.label": "Line total", @@ -157,15 +157,15 @@ "personal.detail.replace_cart.body": "is already in your cart. Replace it with items from", "personal.detail.replace_cart.action": "Replace cart", "personal.detail.keep_current_cart.action": "Keep current cart", - "personal.checkout.title": "Order review", - "personal.checkout.back_action": "Back to cart", - "personal.checkout.contact.title": "Contact", - "personal.checkout.field.name": "Name", - "personal.checkout.field.email": "Email", - "personal.checkout.field.phone": "Phone", - "personal.checkout.field.order_note": "Order note", - "personal.checkout.local_only.body": "Review the details before placing the order.", - "personal.checkout.place_order.action": "Place order", + "personal.order_review.title": "Order review", + "personal.order_review.back_action": "Back to cart", + "personal.order_review.contact.title": "Contact", + "personal.order_review.field.name": "Name", + "personal.order_review.field.email": "Email", + "personal.order_review.field.phone": "Phone", + "personal.order_review.field.order_note": "Order note", + "personal.order_review.local_only.body": "Review the details before placing the order.", + "personal.order_review.place_order.action": "Place order", "orders.title": "Orders", "orders.filters.title": "View", "orders.summary.total": "Total orders",