commit 49e50ffad41bf0218c8e65dc92a056cf9f971d7a
parent 1b38bcb73ddc704114141754a44bc0179ff65b12
Author: triesap <tyson@radroots.org>
Date: Tue, 26 May 2026 01:24:55 +0000
checkout: require account for order placement
Diffstat:
3 files changed, 275 insertions(+), 125 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -1670,6 +1670,11 @@ impl DesktopAppRuntimeState {
fn place_personal_order(&mut self) -> Result<bool, AppSqliteError> {
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",
+ });
+ }
let (refreshed_cart, refreshed_checkout, refreshed_orders, order_detail, order_export) = {
let Some(sqlite_store) = self.sqlite_store.as_ref() else {
return Ok(false);
@@ -7775,8 +7780,8 @@ mod tests {
use radroots_app_models::{
AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface,
AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId,
- BlackoutPeriodRecord, BuyerCheckoutDraft, BuyerOrderStatus, FarmId,
- FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness,
+ BlackoutPeriodRecord, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerOrderStatus,
+ FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness,
FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection,
FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId,
FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter,
@@ -12007,6 +12012,9 @@ mod tests {
})
.expect("buyer checkout draft should save")
);
+ let checkout = runtime.summary().personal_projection.cart.checkout;
+ assert!(checkout.can_place_order);
+ assert_eq!(checkout.place_order_disabled_reason, None);
assert!(
runtime
.place_personal_order()
@@ -12020,6 +12028,14 @@ mod tests {
);
assert!(summary.personal_projection.cart.cart.lines.is_empty());
assert!(!summary.personal_projection.cart.checkout.can_place_order);
+ assert_eq!(
+ summary
+ .personal_projection
+ .cart
+ .checkout
+ .place_order_disabled_reason,
+ Some(BuyerCheckoutDisabledReason::EmptyCart)
+ );
assert_eq!(summary.personal_projection.orders.list.rows.len(), 1);
assert_eq!(
summary.personal_projection.orders.list.rows[0].farm_display_name,
@@ -12055,6 +12071,137 @@ mod tests {
}
#[test]
+ fn runtime_guest_checkout_requires_account_before_order_write() {
+ let runtime = memory_runtime();
+ let farm_id = FarmId::new();
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_summary(&FarmSummary {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ })
+ .expect("farm summary should save");
+ assert!(
+ runtime
+ .select_active_surface(ActiveSurface::Personal)
+ .expect("surface should switch into marketplace")
+ );
+ let fulfillment_window_id = seed_buyer_marketplace_support(
+ &runtime,
+ "acct_farmer",
+ farm_id,
+ "North field farm",
+ "Friday pickup",
+ );
+ let product_id = seed_product(
+ &runtime,
+ farm_id,
+ "Salad mix",
+ "Spring blend",
+ "published",
+ Some(8),
+ "2026-04-20T09:00:00Z",
+ );
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .execute_batch(&format!(
+ "update products
+ set availability_window_id = '{fulfillment_window_id}'
+ where id = '{product_id}'"
+ ))
+ .expect("buyer detail product should attach a fulfillment window");
+ assert!(
+ runtime
+ .open_personal_product_detail(PersonalSection::Browse, product_id)
+ .expect("buyer detail should open")
+ );
+ assert!(
+ runtime
+ .add_personal_product_to_cart(PersonalSection::Browse, false)
+ .expect("buyer product should add to cart")
+ );
+ assert!(
+ runtime
+ .save_personal_checkout_draft(BuyerCheckoutDraft {
+ name: "Casey Buyer".to_owned(),
+ email: "casey@example.com".to_owned(),
+ phone: "555-0101".to_owned(),
+ order_note: "Leave by the cooler".to_owned(),
+ })
+ .expect("buyer checkout draft should save")
+ );
+
+ let ready_summary = runtime.summary();
+ assert!(
+ !ready_summary
+ .personal_projection
+ .cart
+ .checkout
+ .can_place_order
+ );
+ assert_eq!(
+ ready_summary
+ .personal_projection
+ .cart
+ .checkout
+ .place_order_disabled_reason,
+ Some(BuyerCheckoutDisabledReason::AccountRequired)
+ );
+ assert_eq!(
+ ready_summary
+ .personal_projection
+ .cart
+ .checkout
+ .summary
+ .line_count,
+ 1
+ );
+
+ let error = runtime
+ .place_personal_order()
+ .expect_err("guest checkout should require an account");
+ assert!(matches!(error, AppSqliteError::InvalidProjection { .. }));
+
+ let summary = runtime.summary();
+ assert_eq!(
+ summary.shell_projection.selected_section,
+ ShellSection::Personal(PersonalSection::Cart)
+ );
+ assert_eq!(summary.personal_projection.cart.cart.lines.len(), 1);
+ assert_eq!(summary.personal_projection.orders.list.rows.len(), 0);
+ let order_count: i64 = runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .query_row("select count(*) from orders", [], |row| row.get(0))
+ .expect("order count should load");
+ let coordination_count: i64 = runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .query_row(
+ "select count(*) from buyer_order_coordination_records",
+ [],
+ |row| row.get(0),
+ )
+ .expect("coordination count should load");
+ assert_eq!(order_count, 0);
+ assert_eq!(coordination_count, 0);
+ }
+
+ #[test]
fn runtime_places_supported_buyer_order_into_shared_local_events() {
let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event");
assert!(
diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs
@@ -1347,11 +1347,34 @@ pub struct BuyerCheckoutSummaryProjection {
pub currency_code: Option<String>,
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum BuyerCheckoutDisabledReason {
+ EmptyCart,
+ MissingFulfillment,
+ MissingName,
+ MissingEmail,
+ AccountRequired,
+}
+
+impl BuyerCheckoutDisabledReason {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::EmptyCart => "empty_cart",
+ Self::MissingFulfillment => "missing_fulfillment",
+ Self::MissingName => "missing_name",
+ Self::MissingEmail => "missing_email",
+ Self::AccountRequired => "account_required",
+ }
+ }
+}
+
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct BuyerCheckoutProjection {
pub draft: BuyerCheckoutDraft,
pub summary: BuyerCheckoutSummaryProjection,
pub can_place_order: bool,
+ pub place_order_disabled_reason: Option<BuyerCheckoutDisabledReason>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -2563,14 +2586,14 @@ mod tests {
AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface,
ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind,
AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection,
- BuyerCartProjection, 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,
+ 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,
@@ -3047,6 +3070,30 @@ mod tests {
}
#[test]
+ fn buyer_checkout_disabled_reason_storage_keys_are_stable() {
+ assert_eq!(
+ BuyerCheckoutDisabledReason::EmptyCart.storage_key(),
+ "empty_cart"
+ );
+ assert_eq!(
+ BuyerCheckoutDisabledReason::MissingFulfillment.storage_key(),
+ "missing_fulfillment"
+ );
+ assert_eq!(
+ BuyerCheckoutDisabledReason::MissingName.storage_key(),
+ "missing_name"
+ );
+ assert_eq!(
+ BuyerCheckoutDisabledReason::MissingEmail.storage_key(),
+ "missing_email"
+ );
+ assert_eq!(
+ BuyerCheckoutDisabledReason::AccountRequired.storage_key(),
+ "account_required"
+ );
+ }
+
+ #[test]
fn product_attention_stock_and_projection_states_are_explicit() {
let row = ProductsListRow {
product_id: super::ProductId::new(),
@@ -3640,6 +3687,7 @@ mod tests {
currency_code: Some("USD".to_owned()),
},
can_place_order: true,
+ place_order_disabled_reason: None,
};
let orders = BuyerOrdersProjection {
rows: vec![BuyerOrdersListRow {
diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs
@@ -2,13 +2,13 @@ use std::collections::{BTreeMap, BTreeSet};
use radroots_app_models::{
BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection,
- BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerCheckoutSummaryProjection, BuyerContext,
- BuyerListingRow, BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderStatus,
- BuyerOrdersListRow, BuyerOrdersProjection, BuyerProductDetailProjection, FarmId,
- FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow, OrderId, OrderStatus,
- ProductAvailabilityState, ProductAvailabilitySummary, ProductId, ProductPricePresentation,
- ProductStatus, ProductStockState, ProductStockSummary, RepeatDemandEligibility,
- RepeatDemandHandoffProjection,
+ BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerCheckoutProjection,
+ BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection,
+ BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection,
+ BuyerProductDetailProjection, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow,
+ OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId,
+ ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary,
+ RepeatDemandEligibility, RepeatDemandHandoffProjection,
};
use rusqlite::{Connection, OptionalExtension, params};
use serde_json::Value;
@@ -277,6 +277,8 @@ impl<'a> AppBuyerRepository<'a> {
.map(BuyerCartHeader::into_checkout_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);
Ok(BuyerCheckoutProjection {
draft: draft.clone(),
@@ -287,10 +289,8 @@ impl<'a> AppBuyerRepository<'a> {
subtotal_minor_units: cart.subtotal_minor_units,
currency_code: cart.currency_code.clone(),
},
- can_place_order: !cart.lines.is_empty()
- && fulfillment_summary.is_some()
- && !draft.name.trim().is_empty()
- && !draft.email.trim().is_empty(),
+ can_place_order: place_order_disabled_reason.is_none(),
+ place_order_disabled_reason,
})
}
@@ -352,9 +352,9 @@ impl<'a> AppBuyerRepository<'a> {
let cart = self.build_cart_projection(Some(header.clone()), line_records.clone())?;
let checkout = self.load_buyer_checkout(context)?;
- if !checkout.can_place_order {
+ if let Some(disabled_reason) = checkout.place_order_disabled_reason {
return Err(AppSqliteError::InvalidProjection {
- reason: "buyer checkout is not ready",
+ reason: buyer_checkout_disabled_error(disabled_reason),
});
}
@@ -2388,6 +2388,44 @@ fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<Strin
.then_some(first)
}
+fn buyer_checkout_disabled_reason(
+ context: &BuyerContext,
+ cart: &BuyerCartProjection,
+ fulfillment_summary: Option<&String>,
+ draft: &BuyerCheckoutDraft,
+) -> Option<BuyerCheckoutDisabledReason> {
+ if cart.lines.is_empty() {
+ return Some(BuyerCheckoutDisabledReason::EmptyCart);
+ }
+ if fulfillment_summary.is_none() {
+ return Some(BuyerCheckoutDisabledReason::MissingFulfillment);
+ }
+ if draft.name.trim().is_empty() {
+ return Some(BuyerCheckoutDisabledReason::MissingName);
+ }
+ if draft.email.trim().is_empty() {
+ return Some(BuyerCheckoutDisabledReason::MissingEmail);
+ }
+ if matches!(context, BuyerContext::Guest) {
+ return Some(BuyerCheckoutDisabledReason::AccountRequired);
+ }
+ None
+}
+
+fn buyer_checkout_disabled_error(reason: BuyerCheckoutDisabledReason) -> &'static str {
+ match reason {
+ BuyerCheckoutDisabledReason::EmptyCart => "buyer checkout cart is empty",
+ BuyerCheckoutDisabledReason::MissingFulfillment => {
+ "buyer checkout 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"
+ }
+ }
+}
+
fn shared_fulfillment_window_id(
lines: &[BuyerCartLineRecord],
) -> Result<Option<FulfillmentWindowId>, AppSqliteError> {
@@ -2662,16 +2700,13 @@ mod tests {
use std::collections::BTreeSet;
use radroots_app_models::{
- BuyerContext, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderId, OrderStatus,
- PickupLocationId, ProductId,
+ BuyerCheckoutDisabledReason, BuyerContext, FarmId, FarmOrderMethod, FulfillmentWindowId,
+ OrderId, PickupLocationId, ProductId,
};
use rusqlite::{Connection, params};
use serde_json::json;
- use crate::{
- AppSqliteError, AppSqliteStore, BuyerOrderCoordinationState, BuyerRepeatDemandApplyOutcome,
- DatabaseTarget,
- };
+ use crate::{AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget};
use super::AppBuyerRepository;
@@ -2812,7 +2847,7 @@ mod tests {
}
#[test]
- fn buyer_cart_checkout_and_order_history_round_trip_for_guest_context() {
+ fn buyer_checkout_requires_account_before_order_write() {
let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
let connection = store.connection();
let repository = AppBuyerRepository::new(connection);
@@ -2886,62 +2921,25 @@ mod tests {
let checkout = repository
.load_buyer_checkout(&context)
.expect("buyer checkout should load");
- let order_id = repository
+ let error = repository
.place_buyer_order(&context)
- .expect("buyer checkout should place order");
- let buyer_orders = repository
- .load_buyer_orders(&context)
- .expect("buyer orders should load");
- let buyer_order_detail = repository
- .load_buyer_order_detail(&context, order_id)
- .expect("buyer order detail should load")
- .expect("buyer order detail should exist");
+ .expect_err("guest checkout should require an account");
let cart_after_checkout = repository
.load_buyer_cart(&context)
- .expect("buyer cart should load after checkout");
- let coordination = repository
- .load_buyer_order_coordination_record(&context, order_id)
- .expect("buyer order coordination should load")
- .expect("buyer order coordination should exist");
+ .expect("buyer cart should remain after blocked checkout");
- assert!(checkout.can_place_order);
- assert_eq!(checkout.summary.line_count, 1);
- assert_eq!(buyer_orders.rows.len(), 1);
- assert_eq!(
- buyer_orders.rows[0].status,
- radroots_app_models::BuyerOrderStatus::Placed
- );
- assert_eq!(buyer_order_detail.items.len(), 1);
- assert_eq!(
- buyer_order_detail.order_note.as_deref(),
- Some("Leave by the cooler")
- );
- assert!(cart_after_checkout.lines.is_empty());
- assert_eq!(cart_after_checkout.farm_id, None);
- assert_eq!(
- read_order_status(connection, order_id),
- OrderStatus::NeedsAction
- );
- assert_eq!(
- read_order_context_key(connection, order_id).as_deref(),
- Some("guest")
- );
+ assert!(matches!(error, AppSqliteError::InvalidProjection { .. }));
+ assert!(!checkout.can_place_order);
assert_eq!(
- read_order_contact(connection, order_id),
- (
- "Casey Buyer".to_owned(),
- "casey@example.com".to_owned(),
- "555-0101".to_owned(),
- "Leave by the cooler".to_owned(),
- )
+ checkout.place_order_disabled_reason,
+ Some(BuyerCheckoutDisabledReason::AccountRequired)
);
- assert_eq!(coordination.order_id, order_id);
- assert_eq!(coordination.buyer_context_key, "guest");
- assert_eq!(coordination.state, BuyerOrderCoordinationState::Pending);
- assert_eq!(coordination.record_id, None);
- assert_eq!(coordination.payload_json, None);
- assert_eq!(coordination.attempt_count, 0);
- assert_eq!(coordination.last_error_message, None);
+ 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!(row_count(connection, "orders"), 0);
+ assert_eq!(row_count(connection, "order_lines"), 0);
+ assert_eq!(row_count(connection, "buyer_order_coordination_records"), 0);
assert_eq!(row_count(connection, "local_outbox"), 0);
assert_eq!(row_count(connection, "local_conflicts"), 0);
assert_eq!(row_count(connection, "sync_checkpoints"), 0);
@@ -2952,7 +2950,7 @@ mod tests {
let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
let connection = store.connection();
let repository = AppBuyerRepository::new(connection);
- let context = BuyerContext::Guest;
+ let context = BuyerContext::account("acct_buyer");
let farm_id = insert_farm(connection, "Willow Farm", "ready");
let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup");
let future_window_id = insert_window(
@@ -3091,7 +3089,7 @@ mod tests {
let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
let connection = store.connection();
let repository = AppBuyerRepository::new(connection);
- let context = BuyerContext::Guest;
+ let context = BuyerContext::account("acct_buyer");
let farm_id = insert_farm(connection, "Willow Farm", "ready");
let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup");
let future_window_id = insert_window(
@@ -3503,49 +3501,6 @@ mod tests {
.expect("order insert should succeed");
}
- fn read_order_status(connection: &Connection, order_id: OrderId) -> OrderStatus {
- let status = connection
- .query_row(
- "select status from orders where id = ?1 limit 1",
- params![order_id.to_string()],
- |row| row.get::<_, String>(0),
- )
- .expect("order status should load");
-
- super::parse_order_status("orders.status", status).expect("order status should parse")
- }
-
- fn read_order_context_key(connection: &Connection, order_id: OrderId) -> Option<String> {
- connection
- .query_row(
- "select buyer_context_key from orders where id = ?1 limit 1",
- params![order_id.to_string()],
- |row| row.get::<_, Option<String>>(0),
- )
- .expect("order context should load")
- }
-
- fn read_order_contact(
- connection: &Connection,
- order_id: OrderId,
- ) -> (String, String, String, String) {
- connection
- .query_row(
- "select customer_display_name, buyer_email, buyer_phone, buyer_order_note
- from orders where id = ?1 limit 1",
- params![order_id.to_string()],
- |row| {
- Ok((
- row.get::<_, String>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- row.get::<_, String>(3)?,
- ))
- },
- )
- .expect("order contact should load")
- }
-
fn row_count(connection: &Connection, table_name: &str) -> i64 {
let sql = format!("SELECT COUNT(*) FROM {table_name}");