commit 14211595b7980f5232c3184456ea82fd0c6f6a3e
parent 6411af3a08a94db6e6db5b5b5a2a7d6e98a61e3c
Author: triesap <tyson@radroots.org>
Date: Mon, 20 Apr 2026 16:20:11 +0000
feat: add buyer marketplace sqlite seams
Diffstat:
5 files changed, 2226 insertions(+), 6 deletions(-)
diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs
@@ -579,6 +579,31 @@ pub struct LoggedOutStartupProjection {
pub signer_entry: StartupSignerEntryProjection,
}
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind", content = "account_id", rename_all = "snake_case")]
+pub enum BuyerContext {
+ #[default]
+ Guest,
+ Account(String),
+}
+
+impl BuyerContext {
+ pub const fn guest() -> Self {
+ Self::Guest
+ }
+
+ pub fn account(account_id: impl Into<String>) -> Self {
+ Self::Account(account_id.into())
+ }
+
+ pub fn storage_key(&self) -> String {
+ match self {
+ Self::Guest => "guest".to_owned(),
+ Self::Account(account_id) => format!("account:{account_id}"),
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PersonalEntryState {
@@ -726,6 +751,13 @@ impl AppIdentityProjection {
.unwrap_or_else(PersonalEntryProjection::guest),
}
}
+
+ pub fn buyer_context(&self) -> BuyerContext {
+ self.selected_account
+ .as_ref()
+ .map(|account| BuyerContext::account(account.account.account_id.clone()))
+ .unwrap_or_default()
+ }
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
@@ -1873,7 +1905,7 @@ mod tests {
ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind,
AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection,
BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection,
- BuyerCheckoutSummaryProjection, BuyerListingRow, BuyerListingsProjection,
+ BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection,
BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection,
FarmId, FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness,
FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness,
@@ -2164,6 +2196,35 @@ mod tests {
}
#[test]
+ fn buyer_context_defaults_to_guest_and_tracks_selected_account() {
+ let selected_account = SelectedAccountProjection::new(
+ AccountSummary {
+ account_id: "acct_buyer".to_owned(),
+ npub: "npub1buyer".to_owned(),
+ label: Some("Buyer".to_owned()),
+ custody: AccountCustody::LocalManaged,
+ },
+ SelectedSurfaceProjection::new(ActiveSurface::Personal),
+ FarmerActivationProjection::inactive(),
+ );
+ let ready_identity = AppIdentityProjection::ready(Vec::new(), selected_account);
+
+ assert_eq!(BuyerContext::guest().storage_key(), "guest");
+ assert_eq!(
+ BuyerContext::account("acct_buyer").storage_key(),
+ "account:acct_buyer"
+ );
+ assert_eq!(
+ AppIdentityProjection::missing().buyer_context(),
+ BuyerContext::Guest
+ );
+ assert_eq!(
+ ready_identity.buyer_context(),
+ BuyerContext::account("acct_buyer")
+ );
+ }
+
+ #[test]
fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() {
assert_eq!(
LoggedOutStartupProjection::default(),
diff --git a/crates/shared/sqlite/migrations/0009_buyer_marketplace.sql b/crates/shared/sqlite/migrations/0009_buyer_marketplace.sql
@@ -0,0 +1,29 @@
+CREATE TABLE buyer_carts (
+ buyer_context_key TEXT PRIMARY KEY NOT NULL,
+ farm_id TEXT REFERENCES farms(id) ON DELETE SET NULL,
+ buyer_name TEXT NOT NULL DEFAULT '',
+ buyer_email TEXT NOT NULL DEFAULT '',
+ buyer_phone TEXT NOT NULL DEFAULT '',
+ buyer_order_note TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE buyer_cart_lines (
+ buyer_context_key TEXT NOT NULL REFERENCES buyer_carts(buyer_context_key) ON DELETE CASCADE,
+ product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (buyer_context_key, product_id)
+);
+
+CREATE INDEX idx_buyer_cart_lines_context_updated_at
+ ON buyer_cart_lines(buyer_context_key, updated_at DESC, product_id DESC);
+
+ALTER TABLE orders ADD COLUMN buyer_context_key TEXT;
+ALTER TABLE orders ADD COLUMN buyer_email TEXT NOT NULL DEFAULT '';
+ALTER TABLE orders ADD COLUMN buyer_phone TEXT NOT NULL DEFAULT '';
+ALTER TABLE orders ADD COLUMN buyer_order_note TEXT NOT NULL DEFAULT '';
+
+CREATE INDEX idx_orders_buyer_context_updated_at
+ ON orders(buyer_context_key, updated_at DESC, id DESC)
+ WHERE buyer_context_key IS NOT NULL AND trim(buyer_context_key) <> '';
diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs
@@ -0,0 +1,2036 @@
+use std::collections::BTreeSet;
+
+use radroots_app_models::{
+ BuyerCartLineProjection, BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection,
+ BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection,
+ BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection,
+ BuyerProductDetailProjection, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow,
+ OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId,
+ ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary,
+};
+use rusqlite::{Connection, OptionalExtension, params};
+
+use crate::AppSqliteError;
+
+const BUYER_LOW_STOCK_THRESHOLD: u32 = 3;
+
+pub struct AppBuyerRepository<'a> {
+ connection: &'a Connection,
+}
+
+impl<'a> AppBuyerRepository<'a> {
+ pub const fn new(connection: &'a Connection) -> Self {
+ Self { connection }
+ }
+
+ pub fn load_buyer_listings(
+ &self,
+ search_query: &str,
+ fulfillment_methods: &BTreeSet<FarmOrderMethod>,
+ ) -> Result<BuyerListingsProjection, AppSqliteError> {
+ let now_utc = self.current_utc_timestamp()?;
+ let normalized_search = normalize_search_query(search_query);
+ let mut records = self.load_listing_records()?;
+
+ records.retain(|record| {
+ record.is_buyer_visible(&now_utc)
+ && record.matches_search(normalized_search.as_deref())
+ && record.matches_fulfillment_methods(fulfillment_methods)
+ });
+ sort_listing_records(&mut records, &now_utc);
+
+ Ok(BuyerListingsProjection {
+ rows: records
+ .into_iter()
+ .map(|record| record.into_listing_row(&now_utc))
+ .collect::<Result<Vec<_>, _>>()?,
+ })
+ }
+
+ pub fn load_buyer_product_detail(
+ &self,
+ product_id: ProductId,
+ ) -> Result<Option<BuyerProductDetailProjection>, AppSqliteError> {
+ let now_utc = self.current_utc_timestamp()?;
+
+ self.load_listing_record_by_id(product_id)?
+ .filter(|record| record.is_buyer_visible(&now_utc))
+ .map(|record| {
+ Ok(BuyerProductDetailProjection {
+ detail_text: record.detail_text(),
+ listing: record.into_listing_row(&now_utc)?,
+ selected_quantity: 1,
+ })
+ })
+ .transpose()
+ }
+
+ pub fn load_buyer_cart(
+ &self,
+ context: &BuyerContext,
+ ) -> Result<BuyerCartProjection, AppSqliteError> {
+ let context_key = context.storage_key();
+ let header = self.load_cart_header(&context_key)?;
+ let line_records = self.load_cart_line_records(&context_key)?;
+
+ self.build_cart_projection(header, line_records)
+ }
+
+ pub fn replace_buyer_cart(
+ &self,
+ context: &BuyerContext,
+ cart: &BuyerCartProjection,
+ ) -> Result<(), AppSqliteError> {
+ validate_cart_projection(cart)?;
+ let context_key = context.storage_key();
+ let farm_id = if cart.lines.is_empty() {
+ None
+ } else {
+ cart.farm_id
+ };
+
+ self.connection
+ .execute(
+ "insert into buyer_carts (
+ buyer_context_key,
+ farm_id,
+ updated_at
+ ) values (?1, ?2, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ on conflict(buyer_context_key) do update set
+ farm_id = excluded.farm_id,
+ updated_at = excluded.updated_at",
+ params![context_key.as_str(), farm_id.map(|id| id.to_string())],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "save buyer cart header",
+ source,
+ })?;
+ self.connection
+ .execute(
+ "delete from buyer_cart_lines where buyer_context_key = ?1",
+ params![context_key.as_str()],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "clear buyer cart lines",
+ source,
+ })?;
+
+ for line in &cart.lines {
+ self.connection
+ .execute(
+ "insert into buyer_cart_lines (
+ buyer_context_key,
+ product_id,
+ quantity,
+ updated_at
+ ) values (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
+ params![
+ context_key.as_str(),
+ line.product_id.to_string(),
+ i64::from(line.quantity),
+ ],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "save buyer cart line",
+ source,
+ })?;
+ }
+
+ Ok(())
+ }
+
+ pub fn clear_buyer_cart(&self, context: &BuyerContext) -> Result<(), AppSqliteError> {
+ let context_key = context.storage_key();
+
+ self.connection
+ .execute(
+ "delete from buyer_cart_lines where buyer_context_key = ?1",
+ params![context_key.as_str()],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "delete buyer cart lines",
+ source,
+ })?;
+ self.connection
+ .execute(
+ "update buyer_carts
+ set
+ farm_id = null,
+ buyer_order_note = '',
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ where buyer_context_key = ?1",
+ params![context_key.as_str()],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "clear buyer cart header",
+ source,
+ })?;
+
+ Ok(())
+ }
+
+ pub fn load_buyer_checkout(
+ &self,
+ context: &BuyerContext,
+ ) -> Result<BuyerCheckoutProjection, 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)
+ .unwrap_or_default();
+ let fulfillment_summary = shared_fulfillment_summary(&cart.lines);
+
+ Ok(BuyerCheckoutProjection {
+ draft: draft.clone(),
+ summary: BuyerCheckoutSummaryProjection {
+ farm_display_name: cart.farm_display_name.clone(),
+ fulfillment_summary: fulfillment_summary.clone(),
+ line_count: cart.lines.len() as u32,
+ 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(),
+ })
+ }
+
+ pub fn save_buyer_checkout_draft(
+ &self,
+ context: &BuyerContext,
+ draft: &BuyerCheckoutDraft,
+ ) -> Result<(), AppSqliteError> {
+ let context_key = context.storage_key();
+
+ self.connection
+ .execute(
+ "insert into buyer_carts (
+ buyer_context_key,
+ farm_id,
+ updated_at
+ ) values (?1, null, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ on conflict(buyer_context_key) do nothing",
+ params![context_key.as_str()],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "ensure buyer checkout header",
+ source,
+ })?;
+ self.connection
+ .execute(
+ "update buyer_carts
+ set
+ buyer_name = ?2,
+ buyer_email = ?3,
+ buyer_phone = ?4,
+ buyer_order_note = ?5,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ where buyer_context_key = ?1",
+ params![
+ context_key.as_str(),
+ draft.name.trim(),
+ draft.email.trim(),
+ draft.phone.trim(),
+ draft.order_note.trim(),
+ ],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "save buyer checkout draft",
+ source,
+ })?;
+
+ Ok(())
+ }
+
+ pub fn place_buyer_order(&self, context: &BuyerContext) -> Result<OrderId, AppSqliteError> {
+ let context_key = context.storage_key();
+ let header =
+ self.load_cart_header(&context_key)?
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart header is missing",
+ })?;
+ 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)?;
+
+ if !checkout.can_place_order {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer checkout is not ready",
+ });
+ }
+
+ let farm_id = cart.farm_id.ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart farm is missing",
+ })?;
+ let fulfillment_window_id = shared_fulfillment_window_id(&line_records)?;
+ let order_id = OrderId::new();
+ let order_number = self.next_order_number(farm_id)?;
+
+ self.connection
+ .execute_batch("begin immediate transaction")
+ .map_err(|source| AppSqliteError::Query {
+ operation: "begin buyer checkout write",
+ source,
+ })?;
+
+ let result = (|| {
+ self.connection
+ .execute(
+ "insert into orders (
+ id,
+ farm_id,
+ fulfillment_window_id,
+ order_number,
+ customer_display_name,
+ status,
+ updated_at,
+ buyer_context_key,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note
+ ) values (
+ ?1,
+ ?2,
+ ?3,
+ ?4,
+ ?5,
+ 'needs_action',
+ strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
+ ?6,
+ ?7,
+ ?8,
+ ?9
+ )",
+ params![
+ order_id.to_string(),
+ farm_id.to_string(),
+ fulfillment_window_id.map(|id| id.to_string()),
+ order_number,
+ checkout.draft.name.trim(),
+ context_key.as_str(),
+ checkout.draft.email.trim(),
+ checkout.draft.phone.trim(),
+ checkout.draft.order_note.trim(),
+ ],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "insert buyer order",
+ source,
+ })?;
+
+ for (index, line) in line_records.iter().enumerate() {
+ self.connection
+ .execute(
+ "insert into order_lines (
+ id,
+ order_id,
+ title,
+ quantity_value,
+ quantity_unit_label,
+ quantity_display,
+ sort_index
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
+ params![
+ format!("{}:{}", order_id, line.listing.product_id),
+ order_id.to_string(),
+ line.listing.title,
+ i64::from(line.quantity),
+ line.listing.unit_label.as_str(),
+ format_quantity_display(line.quantity, &line.listing.unit_label),
+ index as i64,
+ ],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "insert buyer order line",
+ source,
+ })?;
+ }
+
+ self.connection
+ .execute(
+ "delete from buyer_cart_lines where buyer_context_key = ?1",
+ params![context_key.as_str()],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "clear buyer cart lines after checkout",
+ source,
+ })?;
+ self.connection
+ .execute(
+ "update buyer_carts
+ set
+ farm_id = null,
+ buyer_order_note = '',
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ where buyer_context_key = ?1",
+ params![context_key.as_str()],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "reset buyer cart header after checkout",
+ source,
+ })?;
+
+ Ok(order_id)
+ })();
+
+ match result {
+ Ok(order_id) => {
+ self.connection.execute_batch("commit").map_err(|source| {
+ AppSqliteError::Query {
+ operation: "commit buyer checkout write",
+ source,
+ }
+ })?;
+ Ok(order_id)
+ }
+ Err(error) => {
+ let _ = self.connection.execute_batch("rollback");
+ Err(error)
+ }
+ }
+ }
+
+ pub fn load_buyer_orders(
+ &self,
+ context: &BuyerContext,
+ ) -> Result<BuyerOrdersProjection, AppSqliteError> {
+ let context_key = context.storage_key();
+ let mut statement = self
+ .connection
+ .prepare(
+ "select
+ o.id,
+ o.farm_id,
+ o.order_number,
+ o.status,
+ f.display_name,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at
+ from orders o
+ inner join farms f on f.id = o.farm_id
+ left join fulfillment_windows fw on fw.id = o.fulfillment_window_id
+ where o.buyer_context_key = ?1
+ order by o.updated_at desc, o.id desc",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare buyer orders list",
+ source,
+ })?;
+ let rows = statement
+ .query_map(params![context_key.as_str()], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ row.get::<_, Option<String>>(5)?,
+ row.get::<_, Option<String>>(6)?,
+ row.get::<_, Option<String>>(7)?,
+ ))
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query buyer orders list",
+ source,
+ })?;
+ let mut orders = Vec::new();
+
+ for row in rows {
+ let (
+ order_id,
+ farm_id,
+ order_number,
+ status,
+ farm_display_name,
+ fulfillment_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ ) = row.map_err(|source| AppSqliteError::Query {
+ operation: "read buyer orders list",
+ source,
+ })?;
+
+ orders.push(BuyerOrdersListRow {
+ order_id: parse_typed_id("orders.id", order_id)?,
+ farm_id: parse_typed_id("orders.farm_id", farm_id)?,
+ order_number,
+ farm_display_name,
+ fulfillment_summary: format_fulfillment_summary(
+ fulfillment_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ ),
+ status: BuyerOrderStatus::from(parse_order_status("orders.status", status)?),
+ });
+ }
+
+ Ok(BuyerOrdersProjection { rows: orders })
+ }
+
+ pub fn load_buyer_order_detail(
+ &self,
+ context: &BuyerContext,
+ order_id: OrderId,
+ ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> {
+ let context_key = context.storage_key();
+ let record = self
+ .connection
+ .query_row(
+ "select
+ o.id,
+ o.farm_id,
+ o.order_number,
+ o.status,
+ o.buyer_order_note,
+ f.display_name,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at
+ from orders o
+ inner join farms f on f.id = o.farm_id
+ left join fulfillment_windows fw on fw.id = o.fulfillment_window_id
+ where o.buyer_context_key = ?1 and o.id = ?2
+ limit 1",
+ params![context_key.as_str(), order_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ row.get::<_, String>(5)?,
+ row.get::<_, Option<String>>(6)?,
+ row.get::<_, Option<String>>(7)?,
+ row.get::<_, Option<String>>(8)?,
+ ))
+ },
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load buyer order detail",
+ source,
+ })?;
+
+ record
+ .map(
+ |(
+ order_id,
+ farm_id,
+ order_number,
+ status,
+ order_note,
+ farm_display_name,
+ fulfillment_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ )| {
+ Ok(BuyerOrderDetailProjection {
+ order_id: parse_typed_id("orders.id", order_id.clone())?,
+ farm_id: parse_typed_id("orders.farm_id", farm_id)?,
+ order_number,
+ farm_display_name,
+ fulfillment_summary: format_fulfillment_summary(
+ fulfillment_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ ),
+ status: BuyerOrderStatus::from(parse_order_status(
+ "orders.status",
+ status,
+ )?),
+ items: self.load_order_detail_items(order_id)?,
+ order_note: empty_string_to_none(order_note),
+ })
+ },
+ )
+ .transpose()
+ }
+
+ fn build_cart_projection(
+ &self,
+ header: Option<BuyerCartHeader>,
+ line_records: Vec<BuyerCartLineRecord>,
+ ) -> Result<BuyerCartProjection, AppSqliteError> {
+ let mut lines = Vec::with_capacity(line_records.len());
+ let mut subtotal_minor_units = 0_u32;
+ let mut currency_code = None;
+
+ for line_record in line_records {
+ let line_projection = line_record.into_projection()?;
+ subtotal_minor_units = subtotal_minor_units
+ .checked_add(line_projection.line_total_minor_units)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart subtotal overflowed",
+ })?;
+
+ if currency_code.is_none() {
+ currency_code = Some(line_projection.unit_price.currency_code.clone());
+ }
+ lines.push(line_projection);
+ }
+
+ let farm_id = if lines.is_empty() {
+ None
+ } else {
+ lines.first().map(|line| line.farm_id)
+ }
+ .or(header.as_ref().and_then(|header| header.farm_id));
+ let farm_display_name = if let Some(line) = lines.first() {
+ Some(line.farm_display_name.clone())
+ } else if let Some(farm_id) = farm_id {
+ self.load_farm_display_name(farm_id)?
+ } else {
+ None
+ };
+ let has_lines = !lines.is_empty();
+
+ Ok(BuyerCartProjection {
+ farm_id,
+ farm_display_name,
+ lines,
+ subtotal_minor_units: has_lines.then_some(subtotal_minor_units),
+ currency_code: has_lines.then_some(currency_code.unwrap_or_default()),
+ replace_confirmation: None,
+ })
+ }
+
+ fn current_utc_timestamp(&self) -> Result<String, AppSqliteError> {
+ self.connection
+ .query_row("select strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", [], |row| {
+ row.get(0)
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load buyer current utc timestamp",
+ source,
+ })
+ }
+
+ fn load_listing_records(&self) -> Result<Vec<BuyerListingRecord>, AppSqliteError> {
+ let mut statement = self
+ .connection
+ .prepare(
+ "select
+ p.id,
+ p.farm_id,
+ f.display_name,
+ f.readiness,
+ p.title,
+ p.subtitle,
+ p.status,
+ p.unit_label,
+ p.price_minor_units,
+ p.price_currency,
+ p.stock_count,
+ fw.id,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at,
+ fw.pickup_location_id,
+ coalesce((
+ select max(afs.pickup_enabled)
+ from account_farm_setups afs
+ where afs.saved_farm_id = p.farm_id
+ ), 0),
+ coalesce((
+ select max(afs.delivery_enabled)
+ from account_farm_setups afs
+ where afs.saved_farm_id = p.farm_id
+ ), 0),
+ coalesce((
+ select max(afs.shipping_enabled)
+ from account_farm_setups afs
+ where afs.saved_farm_id = p.farm_id
+ ), 0)
+ from products p
+ inner join farms f on f.id = p.farm_id
+ left join fulfillment_windows fw on fw.id = p.availability_window_id",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare buyer listings query",
+ source,
+ })?;
+ let rows = statement
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ row.get::<_, String>(5)?,
+ row.get::<_, String>(6)?,
+ row.get::<_, String>(7)?,
+ row.get::<_, Option<u32>>(8)?,
+ row.get::<_, String>(9)?,
+ row.get::<_, Option<u32>>(10)?,
+ row.get::<_, Option<String>>(11)?,
+ row.get::<_, Option<String>>(12)?,
+ row.get::<_, Option<String>>(13)?,
+ row.get::<_, Option<String>>(14)?,
+ row.get::<_, Option<String>>(15)?,
+ row.get::<_, i64>(16)?,
+ row.get::<_, i64>(17)?,
+ row.get::<_, i64>(18)?,
+ ))
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query buyer listings",
+ source,
+ })?;
+ let mut records = Vec::new();
+
+ for row in rows {
+ let (
+ product_id,
+ farm_id,
+ farm_display_name,
+ farm_readiness,
+ title,
+ subtitle,
+ status,
+ unit_label,
+ price_minor_units,
+ price_currency,
+ stock_count,
+ fulfillment_window_id,
+ fulfillment_window_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ pickup_location_id,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ ) = row.map_err(|source| AppSqliteError::Query {
+ operation: "read buyer listings",
+ source,
+ })?;
+
+ records.push(BuyerListingRecord {
+ product_id: parse_typed_id("products.id", product_id)?,
+ farm_id: parse_typed_id("products.farm_id", farm_id)?,
+ farm_display_name,
+ farm_is_ready: farm_readiness == "ready",
+ title,
+ subtitle: empty_string_to_none(subtitle),
+ status: parse_product_status("products.status", status)?,
+ unit_label,
+ price_minor_units,
+ price_currency,
+ stock_count,
+ fulfillment_window_id: parse_optional_typed_id(
+ "products.availability_window_id",
+ fulfillment_window_id,
+ )?,
+ fulfillment_window_label: empty_string_to_none_option(fulfillment_window_label),
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ pickup_location_present: pickup_location_id.is_some(),
+ pickup_enabled: parse_sqlite_bool(
+ "account_farm_setups.pickup_enabled",
+ pickup_enabled,
+ )?,
+ delivery_enabled: parse_sqlite_bool(
+ "account_farm_setups.delivery_enabled",
+ delivery_enabled,
+ )?,
+ shipping_enabled: parse_sqlite_bool(
+ "account_farm_setups.shipping_enabled",
+ shipping_enabled,
+ )?,
+ });
+ }
+
+ Ok(records)
+ }
+
+ fn load_listing_record_by_id(
+ &self,
+ product_id: ProductId,
+ ) -> Result<Option<BuyerListingRecord>, AppSqliteError> {
+ Ok(self
+ .load_listing_records()?
+ .into_iter()
+ .find(|record| record.product_id == product_id))
+ }
+
+ fn load_cart_header(
+ &self,
+ context_key: &str,
+ ) -> Result<Option<BuyerCartHeader>, AppSqliteError> {
+ self.connection
+ .query_row(
+ "select
+ farm_id,
+ buyer_name,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note
+ from buyer_carts
+ where buyer_context_key = ?1
+ limit 1",
+ params![context_key],
+ |row| {
+ Ok((
+ row.get::<_, Option<String>>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ ))
+ },
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load buyer cart header",
+ source,
+ })?
+ .map(
+ |(farm_id, buyer_name, buyer_email, buyer_phone, buyer_order_note)| {
+ Ok(BuyerCartHeader {
+ farm_id: parse_optional_typed_id("buyer_carts.farm_id", farm_id)?,
+ buyer_name,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note,
+ })
+ },
+ )
+ .transpose()
+ }
+
+ fn load_cart_line_records(
+ &self,
+ context_key: &str,
+ ) -> Result<Vec<BuyerCartLineRecord>, AppSqliteError> {
+ let now_utc = self.current_utc_timestamp()?;
+ let mut statement = self
+ .connection
+ .prepare(
+ "select
+ bcl.quantity,
+ p.id,
+ p.farm_id,
+ f.display_name,
+ f.readiness,
+ p.title,
+ p.subtitle,
+ p.status,
+ p.unit_label,
+ p.price_minor_units,
+ p.price_currency,
+ p.stock_count,
+ fw.id,
+ fw.label,
+ fw.starts_at,
+ fw.ends_at,
+ fw.pickup_location_id,
+ coalesce((
+ select max(afs.pickup_enabled)
+ from account_farm_setups afs
+ where afs.saved_farm_id = p.farm_id
+ ), 0),
+ coalesce((
+ select max(afs.delivery_enabled)
+ from account_farm_setups afs
+ where afs.saved_farm_id = p.farm_id
+ ), 0),
+ coalesce((
+ select max(afs.shipping_enabled)
+ from account_farm_setups afs
+ where afs.saved_farm_id = p.farm_id
+ ), 0)
+ from buyer_cart_lines bcl
+ inner join products p on p.id = bcl.product_id
+ inner join farms f on f.id = p.farm_id
+ left join fulfillment_windows fw on fw.id = p.availability_window_id
+ where bcl.buyer_context_key = ?1
+ order by bcl.updated_at desc, p.id desc",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare buyer cart lines",
+ source,
+ })?;
+ let rows = statement
+ .query_map(params![context_key], |row| {
+ Ok((
+ row.get::<_, u32>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, String>(4)?,
+ row.get::<_, String>(5)?,
+ row.get::<_, String>(6)?,
+ row.get::<_, String>(7)?,
+ row.get::<_, String>(8)?,
+ row.get::<_, Option<u32>>(9)?,
+ row.get::<_, String>(10)?,
+ row.get::<_, Option<u32>>(11)?,
+ row.get::<_, Option<String>>(12)?,
+ row.get::<_, Option<String>>(13)?,
+ row.get::<_, Option<String>>(14)?,
+ row.get::<_, Option<String>>(15)?,
+ row.get::<_, Option<String>>(16)?,
+ row.get::<_, i64>(17)?,
+ row.get::<_, i64>(18)?,
+ row.get::<_, i64>(19)?,
+ ))
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query buyer cart lines",
+ source,
+ })?;
+ let mut line_records = Vec::new();
+
+ for row in rows {
+ let (
+ quantity,
+ product_id,
+ farm_id,
+ farm_display_name,
+ farm_readiness,
+ title,
+ subtitle,
+ status,
+ unit_label,
+ price_minor_units,
+ price_currency,
+ stock_count,
+ fulfillment_window_id,
+ fulfillment_window_label,
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ pickup_location_id,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ ) = row.map_err(|source| AppSqliteError::Query {
+ operation: "read buyer cart lines",
+ source,
+ })?;
+ let listing = BuyerListingRecord {
+ product_id: parse_typed_id("products.id", product_id)?,
+ farm_id: parse_typed_id("products.farm_id", farm_id)?,
+ farm_display_name,
+ farm_is_ready: farm_readiness == "ready",
+ title,
+ subtitle: empty_string_to_none(subtitle),
+ status: parse_product_status("products.status", status)?,
+ unit_label,
+ price_minor_units,
+ price_currency,
+ stock_count,
+ fulfillment_window_id: parse_optional_typed_id(
+ "products.availability_window_id",
+ fulfillment_window_id,
+ )?,
+ fulfillment_window_label: empty_string_to_none_option(fulfillment_window_label),
+ fulfillment_starts_at,
+ fulfillment_ends_at,
+ pickup_location_present: pickup_location_id.is_some(),
+ pickup_enabled: parse_sqlite_bool(
+ "account_farm_setups.pickup_enabled",
+ pickup_enabled,
+ )?,
+ delivery_enabled: parse_sqlite_bool(
+ "account_farm_setups.delivery_enabled",
+ delivery_enabled,
+ )?,
+ shipping_enabled: parse_sqlite_bool(
+ "account_farm_setups.shipping_enabled",
+ shipping_enabled,
+ )?,
+ };
+
+ if listing.is_buyer_visible(&now_utc) {
+ line_records.push(BuyerCartLineRecord { listing, quantity });
+ }
+ }
+
+ Ok(line_records)
+ }
+
+ fn load_order_detail_items(
+ &self,
+ order_id: String,
+ ) -> Result<Vec<OrderDetailItemRow>, AppSqliteError> {
+ let mut statement = self
+ .connection
+ .prepare(
+ "select title, quantity_display
+ from order_lines
+ where order_id = ?1
+ order by sort_index asc, id asc",
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "prepare buyer order detail items",
+ source,
+ })?;
+ let rows = statement
+ .query_map(params![order_id], |row| {
+ Ok(OrderDetailItemRow {
+ title: row.get(0)?,
+ quantity_display: row.get(1)?,
+ })
+ })
+ .map_err(|source| AppSqliteError::Query {
+ operation: "query buyer order detail items",
+ source,
+ })?;
+
+ rows.collect::<Result<Vec<_>, _>>()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "read buyer order detail items",
+ source,
+ })
+ }
+
+ fn load_farm_display_name(&self, farm_id: FarmId) -> Result<Option<String>, AppSqliteError> {
+ self.connection
+ .query_row(
+ "select display_name from farms where id = ?1 limit 1",
+ params![farm_id.to_string()],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load buyer cart farm display name",
+ source,
+ })
+ }
+
+ fn next_order_number(&self, farm_id: FarmId) -> Result<String, AppSqliteError> {
+ let max_suffix = self
+ .connection
+ .query_row(
+ "select coalesce(max(cast(substr(order_number, 3) as integer)), 999)
+ from orders
+ where farm_id = ?1
+ and order_number like 'R-%'
+ and substr(order_number, 3) glob '[0-9]*'",
+ params![farm_id.to_string()],
+ |row| row.get::<_, i64>(0),
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load next buyer order number",
+ source,
+ })?;
+
+ Ok(format!("R-{}", max_suffix + 1))
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct BuyerCartHeader {
+ farm_id: Option<FarmId>,
+ buyer_name: String,
+ buyer_email: String,
+ buyer_phone: String,
+ buyer_order_note: String,
+}
+
+impl BuyerCartHeader {
+ fn into_checkout_draft(self) -> BuyerCheckoutDraft {
+ BuyerCheckoutDraft {
+ name: self.buyer_name,
+ email: self.buyer_email,
+ phone: self.buyer_phone,
+ order_note: self.buyer_order_note,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct BuyerListingRecord {
+ product_id: ProductId,
+ farm_id: FarmId,
+ farm_display_name: String,
+ farm_is_ready: bool,
+ title: String,
+ subtitle: Option<String>,
+ status: ProductStatus,
+ unit_label: String,
+ price_minor_units: Option<u32>,
+ price_currency: String,
+ stock_count: Option<u32>,
+ fulfillment_window_id: Option<FulfillmentWindowId>,
+ fulfillment_window_label: Option<String>,
+ fulfillment_starts_at: Option<String>,
+ fulfillment_ends_at: Option<String>,
+ pickup_location_present: bool,
+ pickup_enabled: bool,
+ delivery_enabled: bool,
+ shipping_enabled: bool,
+}
+
+impl BuyerListingRecord {
+ fn is_buyer_visible(&self, now_utc: &str) -> bool {
+ self.farm_is_ready
+ && self.status == ProductStatus::Published
+ && self.stock_count.is_some_and(|quantity| quantity > 0)
+ && self.price_minor_units.is_some_and(|amount| amount > 0)
+ && !self.unit_label.trim().is_empty()
+ && self.fulfillment_window_id.is_some()
+ && self
+ .fulfillment_ends_at
+ .as_deref()
+ .is_some_and(|ends_at| ends_at >= now_utc)
+ && !self.fulfillment_methods().is_empty()
+ }
+
+ fn matches_search(&self, search_query: Option<&str>) -> bool {
+ let Some(search_query) = search_query else {
+ return true;
+ };
+
+ self.title.to_lowercase().contains(search_query)
+ || self
+ .subtitle
+ .as_deref()
+ .is_some_and(|subtitle| subtitle.to_lowercase().contains(search_query))
+ || self.farm_display_name.to_lowercase().contains(search_query)
+ }
+
+ fn matches_fulfillment_methods(&self, selected: &BTreeSet<FarmOrderMethod>) -> bool {
+ selected.is_empty()
+ || self
+ .fulfillment_methods()
+ .iter()
+ .any(|method| selected.contains(method))
+ }
+
+ fn detail_text(&self) -> Option<String> {
+ self.subtitle.clone()
+ }
+
+ fn into_listing_row(self, now_utc: &str) -> Result<BuyerListingRow, AppSqliteError> {
+ let price = self
+ .price_presentation()
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer listing price is missing",
+ })?;
+ let availability = self.availability_summary(now_utc)?;
+ let stock = self.stock_summary();
+ let fulfillment_methods = self.fulfillment_methods();
+ let next_fulfillment_window_label = Some(self.fulfillment_summary_label()?);
+
+ Ok(BuyerListingRow {
+ product_id: self.product_id,
+ farm_id: self.farm_id,
+ farm_display_name: self.farm_display_name,
+ title: self.title,
+ subtitle: self.subtitle,
+ price,
+ availability,
+ stock,
+ fulfillment_methods,
+ next_fulfillment_window_label,
+ })
+ }
+
+ fn availability_summary(
+ &self,
+ now_utc: &str,
+ ) -> Result<ProductAvailabilitySummary, AppSqliteError> {
+ let starts_at =
+ self.fulfillment_starts_at
+ .clone()
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer listing fulfillment start is missing",
+ })?;
+ let ends_at =
+ self.fulfillment_ends_at
+ .clone()
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer listing fulfillment end is missing",
+ })?;
+ let state = if starts_at.as_str() <= now_utc {
+ ProductAvailabilityState::Open
+ } else {
+ ProductAvailabilityState::Scheduled
+ };
+
+ Ok(ProductAvailabilitySummary {
+ state,
+ label: self
+ .fulfillment_window_label
+ .clone()
+ .unwrap_or_else(|| format_window_label(&starts_at, &ends_at)),
+ })
+ }
+
+ fn fulfillment_summary_label(&self) -> Result<String, AppSqliteError> {
+ match (
+ self.fulfillment_window_label.clone(),
+ self.fulfillment_starts_at.as_deref(),
+ self.fulfillment_ends_at.as_deref(),
+ ) {
+ (Some(label), _, _) => Ok(label),
+ (None, Some(starts_at), Some(ends_at)) => Ok(format_window_label(starts_at, ends_at)),
+ _ => Err(AppSqliteError::InvalidProjection {
+ reason: "buyer listing fulfillment summary is missing",
+ }),
+ }
+ }
+
+ fn stock_summary(&self) -> ProductStockSummary {
+ let quantity = self.stock_count;
+ let state = match quantity {
+ Some(0) => ProductStockState::SoldOut,
+ Some(quantity) if quantity <= BUYER_LOW_STOCK_THRESHOLD => ProductStockState::LowStock,
+ Some(_) => ProductStockState::InStock,
+ None => ProductStockState::Unset,
+ };
+
+ ProductStockSummary {
+ quantity,
+ unit_label: Some(self.unit_label.clone()),
+ state,
+ }
+ }
+
+ fn price_presentation(&self) -> Option<ProductPricePresentation> {
+ self.price_minor_units
+ .filter(|amount| *amount > 0)
+ .map(|amount_minor_units| ProductPricePresentation {
+ amount_minor_units,
+ currency_code: normalize_currency_code(&self.price_currency),
+ unit_label: self.unit_label.clone(),
+ })
+ }
+
+ fn fulfillment_methods(&self) -> BTreeSet<FarmOrderMethod> {
+ let mut methods = BTreeSet::new();
+ if self.pickup_enabled
+ || (!self.delivery_enabled && !self.shipping_enabled && self.pickup_location_present)
+ {
+ methods.insert(FarmOrderMethod::Pickup);
+ }
+ if self.delivery_enabled {
+ methods.insert(FarmOrderMethod::Delivery);
+ }
+ if self.shipping_enabled {
+ methods.insert(FarmOrderMethod::Shipping);
+ }
+
+ methods
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct BuyerCartLineRecord {
+ listing: BuyerListingRecord,
+ quantity: u32,
+}
+
+impl BuyerCartLineRecord {
+ fn into_projection(self) -> Result<BuyerCartLineProjection, AppSqliteError> {
+ let unit_price =
+ self.listing
+ .price_presentation()
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart line price is missing",
+ })?;
+ let line_total_minor_units = unit_price
+ .amount_minor_units
+ .checked_mul(self.quantity)
+ .ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart line total overflowed",
+ })?;
+
+ Ok(BuyerCartLineProjection {
+ product_id: self.listing.product_id,
+ farm_id: self.listing.farm_id,
+ farm_display_name: self.listing.farm_display_name.clone(),
+ title: self.listing.title.clone(),
+ quantity: self.quantity,
+ unit_price,
+ line_total_minor_units,
+ fulfillment_summary: self.listing.fulfillment_summary_label()?,
+ })
+ }
+}
+
+fn validate_cart_projection(cart: &BuyerCartProjection) -> Result<(), AppSqliteError> {
+ if cart.lines.is_empty() {
+ return Ok(());
+ }
+
+ let farm_id = cart.farm_id.ok_or(AppSqliteError::InvalidProjection {
+ reason: "buyer cart farm is required when cart has lines",
+ })?;
+
+ for line in &cart.lines {
+ if line.quantity == 0 {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer cart quantities must stay positive",
+ });
+ }
+ if line.farm_id != farm_id {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer cart must remain single farm",
+ });
+ }
+ }
+
+ Ok(())
+}
+
+fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<String> {
+ let first = lines.first()?.fulfillment_summary.clone();
+
+ lines
+ .iter()
+ .all(|line| line.fulfillment_summary == first)
+ .then_some(first)
+}
+
+fn shared_fulfillment_window_id(
+ lines: &[BuyerCartLineRecord],
+) -> Result<Option<FulfillmentWindowId>, AppSqliteError> {
+ let Some(first) = lines.first() else {
+ return Err(AppSqliteError::InvalidProjection {
+ reason: "buyer cart must contain at least one line",
+ });
+ };
+ let first_window_id = first.listing.fulfillment_window_id;
+
+ if lines
+ .iter()
+ .all(|line| line.listing.fulfillment_window_id == first_window_id)
+ {
+ Ok(first_window_id)
+ } else {
+ Err(AppSqliteError::InvalidProjection {
+ reason: "buyer cart must share one fulfillment window at checkout",
+ })
+ }
+}
+
+fn normalize_search_query(search_query: &str) -> Option<String> {
+ let trimmed = search_query.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_lowercase())
+ }
+}
+
+fn sort_listing_records(records: &mut [BuyerListingRecord], now_utc: &str) {
+ records.sort_by(|left, right| {
+ left.fulfillment_starts_at
+ .cmp(&right.fulfillment_starts_at)
+ .then_with(|| {
+ left.availability_state(now_utc)
+ .cmp(&right.availability_state(now_utc))
+ })
+ .then_with(|| {
+ left.farm_display_name
+ .to_lowercase()
+ .cmp(&right.farm_display_name.to_lowercase())
+ })
+ .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase()))
+ .then_with(|| left.product_id.cmp(&right.product_id))
+ });
+}
+
+impl BuyerListingRecord {
+ fn availability_state(&self, now_utc: &str) -> u8 {
+ match self.fulfillment_starts_at.as_deref() {
+ Some(starts_at) if starts_at <= now_utc => 0,
+ Some(_) => 1,
+ None => 2,
+ }
+ }
+}
+
+fn format_window_label(starts_at: &str, ends_at: &str) -> String {
+ let start_date = starts_at.get(0..10);
+ let start_time = starts_at.get(11..16);
+ let end_date = ends_at.get(0..10);
+ let end_time = ends_at.get(11..16);
+
+ match (start_date, start_time, end_date, end_time) {
+ (Some(start_date), Some(start_time), Some(end_date), Some(end_time))
+ if start_date == end_date =>
+ {
+ format!("{start_date} {start_time}-{end_time} UTC")
+ }
+ (Some(start_date), Some(start_time), Some(end_date), Some(end_time)) => {
+ format!("{start_date} {start_time} UTC to {end_date} {end_time} UTC")
+ }
+ _ => starts_at.to_owned(),
+ }
+}
+
+fn format_fulfillment_summary(
+ label: Option<String>,
+ starts_at: Option<String>,
+ ends_at: Option<String>,
+) -> String {
+ if let Some(label) = empty_string_to_none_option(label) {
+ return label;
+ }
+
+ match (starts_at.as_deref(), ends_at.as_deref()) {
+ (Some(starts_at), Some(ends_at)) => format_window_label(starts_at, ends_at),
+ _ => "Fulfillment pending".to_owned(),
+ }
+}
+
+fn format_quantity_display(quantity: u32, unit_label: &str) -> String {
+ let trimmed = unit_label.trim();
+ if trimmed.is_empty() {
+ quantity.to_string()
+ } else {
+ format!("{quantity} {trimmed}")
+ }
+}
+
+fn normalize_currency_code(value: &str) -> String {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ "USD".to_owned()
+ } else {
+ trimmed.to_ascii_uppercase()
+ }
+}
+
+fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError>
+where
+ T: std::str::FromStr,
+{
+ value
+ .parse()
+ .map_err(|_| AppSqliteError::DecodeId { field, value })
+}
+
+fn parse_optional_typed_id<T>(
+ field: &'static str,
+ value: Option<String>,
+) -> Result<Option<T>, AppSqliteError>
+where
+ T: std::str::FromStr,
+{
+ value.map(|value| parse_typed_id(field, value)).transpose()
+}
+
+fn parse_sqlite_bool(field: &'static str, value: i64) -> Result<bool, AppSqliteError> {
+ match value {
+ 0 => Ok(false),
+ 1 => Ok(true),
+ _ => Err(AppSqliteError::DecodeEnum {
+ field,
+ value: value.to_string(),
+ }),
+ }
+}
+
+fn parse_product_status(
+ field: &'static str,
+ value: String,
+) -> Result<ProductStatus, AppSqliteError> {
+ match value.as_str() {
+ "draft" => Ok(ProductStatus::Draft),
+ "published" => Ok(ProductStatus::Published),
+ "paused" => Ok(ProductStatus::Paused),
+ "archived" => Ok(ProductStatus::Archived),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, AppSqliteError> {
+ match value.as_str() {
+ "needs_action" => Ok(OrderStatus::NeedsAction),
+ "scheduled" => Ok(OrderStatus::Scheduled),
+ "packed" => Ok(OrderStatus::Packed),
+ "completed" => Ok(OrderStatus::Completed),
+ "refunded" => Ok(OrderStatus::Refunded),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn empty_string_to_none(value: String) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+fn empty_string_to_none_option(value: Option<String>) -> Option<String> {
+ value.and_then(empty_string_to_none)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeSet;
+
+ use radroots_app_models::{
+ BuyerContext, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderId, OrderStatus,
+ PickupLocationId, ProductId,
+ };
+ use rusqlite::{Connection, params};
+
+ use crate::{AppSqliteError, AppSqliteStore, DatabaseTarget};
+
+ use super::AppBuyerRepository;
+
+ #[test]
+ fn buyer_listings_and_product_detail_follow_catalog_truth() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let connection = store.connection();
+ let repository = AppBuyerRepository::new(connection);
+ let farm_id = insert_farm(connection, "Willow Farm", "ready");
+ let future_window_id = insert_window(
+ connection,
+ farm_id,
+ Some(insert_pickup_location(connection, farm_id, "Barn pickup")),
+ "Friday pickup",
+ "2099-04-18T16:00:00Z",
+ "2099-04-18T18:00:00Z",
+ );
+
+ insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false);
+ let visible_product_id = insert_product(
+ connection,
+ farm_id,
+ SeedProduct {
+ title: "Salad mix",
+ subtitle: "Spring blend",
+ status: "published",
+ unit_label: "bag",
+ price_minor_units: Some(650),
+ price_currency: "USD",
+ stock_count: Some(8),
+ availability_window_id: Some(future_window_id),
+ },
+ );
+ insert_product(
+ connection,
+ farm_id,
+ SeedProduct {
+ title: "Pea shoots",
+ subtitle: "Tray-grown",
+ status: "draft",
+ unit_label: "bag",
+ price_minor_units: Some(450),
+ price_currency: "USD",
+ stock_count: Some(4),
+ availability_window_id: Some(future_window_id),
+ },
+ );
+ insert_product(
+ connection,
+ farm_id,
+ SeedProduct {
+ title: "Sold out carrots",
+ subtitle: "",
+ status: "published",
+ unit_label: "bunch",
+ price_minor_units: Some(500),
+ price_currency: "USD",
+ stock_count: Some(0),
+ availability_window_id: Some(future_window_id),
+ },
+ );
+
+ let listings = repository
+ .load_buyer_listings("salad", &BTreeSet::from([FarmOrderMethod::Pickup]))
+ .expect("buyer listings should load");
+ let detail = repository
+ .load_buyer_product_detail(visible_product_id)
+ .expect("buyer detail should load")
+ .expect("buyer detail should exist");
+
+ assert_eq!(listings.rows.len(), 1);
+ assert_eq!(listings.rows[0].title, "Salad mix");
+ assert_eq!(
+ listings.rows[0].fulfillment_methods,
+ BTreeSet::from([FarmOrderMethod::Pickup])
+ );
+ assert_eq!(
+ listings.rows[0].next_fulfillment_window_label.as_deref(),
+ Some("Friday pickup")
+ );
+ assert_eq!(detail.selected_quantity, 1);
+ assert_eq!(detail.detail_text.as_deref(), Some("Spring blend"));
+ }
+
+ #[test]
+ fn buyer_cart_checkout_and_order_history_round_trip_for_guest_context() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let connection = store.connection();
+ let repository = AppBuyerRepository::new(connection);
+ let context = BuyerContext::Guest;
+ 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(
+ connection,
+ farm_id,
+ Some(pickup_location_id),
+ "Friday pickup",
+ "2099-04-18T16:00:00Z",
+ "2099-04-18T18:00:00Z",
+ );
+
+ insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false);
+ let product_id = insert_product(
+ connection,
+ farm_id,
+ SeedProduct {
+ title: "Salad mix",
+ subtitle: "Spring blend",
+ status: "published",
+ unit_label: "bag",
+ price_minor_units: Some(650),
+ price_currency: "USD",
+ stock_count: Some(8),
+ availability_window_id: Some(future_window_id),
+ },
+ );
+ let listing = repository
+ .load_buyer_product_detail(product_id)
+ .expect("buyer detail should load")
+ .expect("listing should exist")
+ .listing;
+
+ repository
+ .replace_buyer_cart(
+ &context,
+ &radroots_app_models::BuyerCartProjection {
+ farm_id: Some(farm_id),
+ farm_display_name: Some("Willow Farm".to_owned()),
+ lines: vec![radroots_app_models::BuyerCartLineProjection {
+ product_id: listing.product_id,
+ farm_id: listing.farm_id,
+ farm_display_name: listing.farm_display_name.clone(),
+ title: listing.title.clone(),
+ quantity: 2,
+ unit_price: listing.price.clone(),
+ line_total_minor_units: 1300,
+ fulfillment_summary: "Friday pickup".to_owned(),
+ }],
+ subtotal_minor_units: Some(1300),
+ currency_code: Some("USD".to_owned()),
+ replace_confirmation: None,
+ },
+ )
+ .expect("buyer cart should save");
+ repository
+ .save_buyer_checkout_draft(
+ &context,
+ &radroots_app_models::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 checkout = repository
+ .load_buyer_checkout(&context)
+ .expect("buyer checkout should load");
+ let order_id = 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");
+ let cart_after_checkout = repository
+ .load_buyer_cart(&context)
+ .expect("buyer cart should load after 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_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(),
+ )
+ );
+ }
+
+ #[test]
+ fn buyer_orders_filter_to_context_and_ignore_seller_orders() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let connection = store.connection();
+ let repository = AppBuyerRepository::new(connection);
+ let farm_id = insert_farm(connection, "Willow Farm", "ready");
+
+ insert_order(
+ connection,
+ OrderId::new(),
+ farm_id,
+ "R-100",
+ "needs_action",
+ None,
+ "",
+ "",
+ "",
+ );
+ insert_order(
+ connection,
+ OrderId::new(),
+ farm_id,
+ "R-101",
+ "scheduled",
+ Some("guest"),
+ "guest@example.com",
+ "",
+ "",
+ );
+ insert_order(
+ connection,
+ OrderId::new(),
+ farm_id,
+ "R-102",
+ "packed",
+ Some("account:acct_buyer"),
+ "buyer@example.com",
+ "",
+ "",
+ );
+
+ let guest_orders = repository
+ .load_buyer_orders(&BuyerContext::Guest)
+ .expect("guest orders should load");
+ let account_orders = repository
+ .load_buyer_orders(&BuyerContext::account("acct_buyer"))
+ .expect("account orders should load");
+
+ assert_eq!(guest_orders.rows.len(), 1);
+ assert_eq!(guest_orders.rows[0].order_number, "R-101");
+ assert_eq!(account_orders.rows.len(), 1);
+ assert_eq!(account_orders.rows[0].order_number, "R-102");
+ }
+
+ #[test]
+ fn buyer_cart_rejects_cross_farm_lines() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let farm_id = FarmId::new();
+ let other_farm_id = FarmId::new();
+
+ let error = repository_error(&store, farm_id, other_farm_id);
+
+ assert!(matches!(error, AppSqliteError::InvalidProjection { .. }));
+ }
+
+ fn repository_error(
+ store: &AppSqliteStore,
+ farm_id: FarmId,
+ other_farm_id: FarmId,
+ ) -> AppSqliteError {
+ AppBuyerRepository::new(store.connection())
+ .replace_buyer_cart(
+ &BuyerContext::Guest,
+ &radroots_app_models::BuyerCartProjection {
+ farm_id: Some(farm_id),
+ farm_display_name: Some("Willow Farm".to_owned()),
+ lines: vec![radroots_app_models::BuyerCartLineProjection {
+ product_id: ProductId::new(),
+ farm_id: other_farm_id,
+ farm_display_name: "Other Farm".to_owned(),
+ title: "Mismatch".to_owned(),
+ quantity: 1,
+ unit_price: radroots_app_models::ProductPricePresentation {
+ amount_minor_units: 500,
+ currency_code: "USD".to_owned(),
+ unit_label: "bag".to_owned(),
+ },
+ line_total_minor_units: 500,
+ fulfillment_summary: "Friday pickup".to_owned(),
+ }],
+ subtotal_minor_units: Some(500),
+ currency_code: Some("USD".to_owned()),
+ replace_confirmation: None,
+ },
+ )
+ .expect_err("cross-farm cart should fail")
+ }
+
+ fn insert_farm(connection: &Connection, display_name: &str, readiness: &str) -> FarmId {
+ let farm_id = FarmId::new();
+
+ connection
+ .execute(
+ "insert into farms (
+ id,
+ display_name,
+ readiness,
+ timezone,
+ currency_code,
+ created_at,
+ updated_at
+ ) values (?1, ?2, ?3, 'UTC', 'USD', '2026-04-20T08:00:00Z', '2026-04-20T08:00:00Z')",
+ params![farm_id.to_string(), display_name, readiness],
+ )
+ .expect("farm insert should succeed");
+
+ farm_id
+ }
+
+ fn insert_pickup_location(
+ connection: &Connection,
+ farm_id: FarmId,
+ label: &str,
+ ) -> PickupLocationId {
+ let pickup_location_id = PickupLocationId::new();
+
+ connection
+ .execute(
+ "insert into pickup_locations (
+ id,
+ farm_id,
+ label,
+ address_line,
+ directions,
+ is_default,
+ created_at,
+ updated_at
+ ) values (?1, ?2, ?3, '14 County Road', null, 1, '2026-04-20T08:00:00Z', '2026-04-20T08:00:00Z')",
+ params![pickup_location_id.to_string(), farm_id.to_string(), label],
+ )
+ .expect("pickup location insert should succeed");
+
+ pickup_location_id
+ }
+
+ fn insert_window(
+ connection: &Connection,
+ farm_id: FarmId,
+ pickup_location_id: Option<PickupLocationId>,
+ label: &str,
+ starts_at: &str,
+ ends_at: &str,
+ ) -> FulfillmentWindowId {
+ let fulfillment_window_id = FulfillmentWindowId::new();
+
+ connection
+ .execute(
+ "insert into fulfillment_windows (
+ id,
+ farm_id,
+ starts_at,
+ ends_at,
+ capacity_limit,
+ created_at,
+ updated_at,
+ pickup_location_id,
+ label,
+ order_cutoff_at
+ ) values (?1, ?2, ?3, ?4, null, ?3, ?3, ?5, ?6, ?3)",
+ params![
+ fulfillment_window_id.to_string(),
+ farm_id.to_string(),
+ starts_at,
+ ends_at,
+ pickup_location_id.map(|id| id.to_string()),
+ label,
+ ],
+ )
+ .expect("window insert should succeed");
+
+ fulfillment_window_id
+ }
+
+ fn insert_farm_setup_binding(
+ connection: &Connection,
+ account_id: &str,
+ farm_id: FarmId,
+ pickup_enabled: bool,
+ delivery_enabled: bool,
+ shipping_enabled: bool,
+ ) {
+ connection
+ .execute(
+ "insert into account_farm_setups (
+ account_id,
+ farm_name,
+ location_or_service_area,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ saved_farm_id,
+ saved_farm_display_name,
+ saved_farm_readiness,
+ updated_at
+ ) values (?1, 'Willow Farm', 'County Road', ?2, ?3, ?4, ?5, 'Willow Farm', 'ready', '2026-04-20T08:00:00Z')",
+ params![
+ account_id,
+ i64::from(pickup_enabled),
+ i64::from(delivery_enabled),
+ i64::from(shipping_enabled),
+ farm_id.to_string(),
+ ],
+ )
+ .expect("farm setup binding insert should succeed");
+ }
+
+ struct SeedProduct<'a> {
+ title: &'a str,
+ subtitle: &'a str,
+ status: &'a str,
+ unit_label: &'a str,
+ price_minor_units: Option<u32>,
+ price_currency: &'a str,
+ stock_count: Option<u32>,
+ availability_window_id: Option<FulfillmentWindowId>,
+ }
+
+ fn insert_product(
+ connection: &Connection,
+ farm_id: FarmId,
+ product: SeedProduct<'_>,
+ ) -> ProductId {
+ let product_id = ProductId::new();
+
+ connection
+ .execute(
+ "insert into products (
+ id,
+ farm_id,
+ title,
+ subtitle,
+ status,
+ unit_label,
+ price_minor_units,
+ price_currency,
+ stock_count,
+ availability_window_id,
+ updated_at
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '2026-04-20T09:00:00Z')",
+ params![
+ product_id.to_string(),
+ farm_id.to_string(),
+ product.title,
+ product.subtitle,
+ product.status,
+ product.unit_label,
+ product.price_minor_units,
+ product.price_currency,
+ product.stock_count,
+ product.availability_window_id.map(|id| id.to_string()),
+ ],
+ )
+ .expect("product insert should succeed");
+
+ product_id
+ }
+
+ fn insert_order(
+ connection: &Connection,
+ order_id: OrderId,
+ farm_id: FarmId,
+ order_number: &str,
+ status: &str,
+ buyer_context_key: Option<&str>,
+ buyer_email: &str,
+ buyer_phone: &str,
+ buyer_order_note: &str,
+ ) {
+ connection
+ .execute(
+ "insert into orders (
+ id,
+ farm_id,
+ fulfillment_window_id,
+ order_number,
+ customer_display_name,
+ status,
+ updated_at,
+ buyer_context_key,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note
+ ) values (?1, ?2, null, ?3, 'Casey', ?4, '2026-04-20T10:00:00Z', ?5, ?6, ?7, ?8)",
+ params![
+ order_id.to_string(),
+ farm_id.to_string(),
+ order_number,
+ status,
+ buyer_context_key,
+ buyer_email,
+ buyer_phone,
+ buyer_order_note,
+ ],
+ )
+ .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")
+ }
+}
diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs
@@ -2,6 +2,7 @@
mod activation;
mod activity;
+mod buyer;
mod error;
mod farm_rules;
mod farm_setup;
@@ -10,14 +11,17 @@ mod orders;
mod products;
mod today;
-use std::{fs, path::PathBuf, time::Duration};
+use std::{collections::BTreeSet, fs, path::PathBuf, time::Duration};
use radroots_app_models::{
AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind,
- FarmId, FarmRulesProjection, FarmSetupProjection, FarmSummary, OrderDetailProjection, OrderId,
- OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState,
- ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection,
- ProductsSort, TodayAgendaProjection,
+ BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerContext,
+ BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrdersProjection,
+ BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmRulesProjection,
+ FarmSetupProjection, FarmSummary, OrderDetailProjection, OrderId, OrdersListProjection,
+ OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, ProductEditorDraft,
+ ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort,
+ TodayAgendaProjection,
};
use rusqlite::Connection;
@@ -25,6 +29,7 @@ pub use activation::AppActivationRepository;
pub use activity::{
APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository,
};
+pub use buyer::AppBuyerRepository;
pub use error::AppSqliteError;
pub use farm_rules::{AppFarmRulesRepository, derive_farm_rules_readiness};
pub use farm_setup::AppFarmSetupRepository;
@@ -87,6 +92,10 @@ impl AppSqliteStore {
AppFarmRulesRepository::new(&self.connection)
}
+ pub fn buyer_repository(&self) -> AppBuyerRepository<'_> {
+ AppBuyerRepository::new(&self.connection)
+ }
+
pub fn products_repository(&self) -> AppProductsRepository<'_> {
AppProductsRepository::new(&self.connection)
}
@@ -261,6 +270,78 @@ impl AppSqliteStore {
self.products_repository()
.evaluate_product_publish_blockers(product_id)
}
+
+ pub fn load_buyer_listings(
+ &self,
+ search_query: &str,
+ fulfillment_methods: &BTreeSet<FarmOrderMethod>,
+ ) -> Result<BuyerListingsProjection, AppSqliteError> {
+ self.buyer_repository()
+ .load_buyer_listings(search_query, fulfillment_methods)
+ }
+
+ pub fn load_buyer_product_detail(
+ &self,
+ product_id: ProductId,
+ ) -> Result<Option<BuyerProductDetailProjection>, AppSqliteError> {
+ self.buyer_repository()
+ .load_buyer_product_detail(product_id)
+ }
+
+ pub fn load_buyer_cart(
+ &self,
+ context: &BuyerContext,
+ ) -> Result<BuyerCartProjection, AppSqliteError> {
+ self.buyer_repository().load_buyer_cart(context)
+ }
+
+ pub fn replace_buyer_cart(
+ &self,
+ context: &BuyerContext,
+ cart: &BuyerCartProjection,
+ ) -> Result<(), AppSqliteError> {
+ self.buyer_repository().replace_buyer_cart(context, cart)
+ }
+
+ pub fn clear_buyer_cart(&self, context: &BuyerContext) -> Result<(), AppSqliteError> {
+ self.buyer_repository().clear_buyer_cart(context)
+ }
+
+ pub fn load_buyer_checkout(
+ &self,
+ context: &BuyerContext,
+ ) -> Result<BuyerCheckoutProjection, AppSqliteError> {
+ self.buyer_repository().load_buyer_checkout(context)
+ }
+
+ pub fn save_buyer_checkout_draft(
+ &self,
+ context: &BuyerContext,
+ draft: &BuyerCheckoutDraft,
+ ) -> Result<(), AppSqliteError> {
+ self.buyer_repository()
+ .save_buyer_checkout_draft(context, draft)
+ }
+
+ pub fn place_buyer_order(&self, context: &BuyerContext) -> Result<OrderId, AppSqliteError> {
+ self.buyer_repository().place_buyer_order(context)
+ }
+
+ pub fn load_buyer_orders(
+ &self,
+ context: &BuyerContext,
+ ) -> Result<BuyerOrdersProjection, AppSqliteError> {
+ self.buyer_repository().load_buyer_orders(context)
+ }
+
+ pub fn load_buyer_order_detail(
+ &self,
+ context: &BuyerContext,
+ order_id: OrderId,
+ ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> {
+ self.buyer_repository()
+ .load_buyer_order_detail(context, order_id)
+ }
}
fn open_connection(target: &DatabaseTarget) -> Result<Connection, AppSqliteError> {
@@ -396,6 +477,8 @@ mod tests {
assert!(table_exists(connection, "pickup_locations"));
assert!(table_exists(connection, "blackout_periods"));
assert!(table_exists(connection, "order_lines"));
+ assert!(table_exists(connection, "buyer_carts"));
+ assert!(table_exists(connection, "buyer_cart_lines"));
assert!(column_exists(connection, "farms", "timezone"));
assert!(column_exists(connection, "farms", "currency_code"));
assert!(column_exists(
@@ -416,6 +499,13 @@ mod tests {
"quantity_unit_label"
));
assert!(column_exists(connection, "order_lines", "quantity_display"));
+ assert!(column_exists(connection, "buyer_carts", "buyer_email"));
+ assert!(column_exists(connection, "buyer_carts", "buyer_phone"));
+ assert!(column_exists(connection, "buyer_carts", "buyer_order_note"));
+ assert!(column_exists(connection, "orders", "buyer_context_key"));
+ assert!(column_exists(connection, "orders", "buyer_email"));
+ assert!(column_exists(connection, "orders", "buyer_phone"));
+ assert!(column_exists(connection, "orders", "buyer_order_note"));
assert_eq!(row_count(connection, "sync_checkpoints"), 1);
drop(store);
diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs
@@ -36,6 +36,10 @@ const MIGRATIONS: &[Migration] = &[
version: 8,
sql: include_str!("../migrations/0008_orders_and_pack_day.sql"),
},
+ Migration {
+ version: 9,
+ sql: include_str!("../migrations/0009_buyer_marketplace.sql"),
+ },
];
pub fn latest_schema_version() -> u32 {