app

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

lib.rs (140967B)


      1 #![forbid(unsafe_code)]
      2 
      3 pub use radroots_app_types::*;
      4 
      5 use radroots_core::RadrootsCoreMoney;
      6 use radroots_events::order::RadrootsOrderEconomics;
      7 use radroots_trade::order::{RadrootsOrderProjection, RadrootsOrderStatus};
      8 use radroots_trade::validation_receipt::{
      9     RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult,
     10     RadrootsValidationReceiptType,
     11 };
     12 use serde::{Deserialize, Serialize};
     13 use std::{collections::BTreeSet, error::Error, fmt, str::FromStr};
     14 use url::Url;
     15 
     16 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
     17 #[serde(rename_all = "snake_case")]
     18 pub enum ActiveSurface {
     19     #[default]
     20     Farmer,
     21     Personal,
     22 }
     23 
     24 impl ActiveSurface {
     25     pub const fn storage_key(self) -> &'static str {
     26         match self {
     27             Self::Farmer => "farmer",
     28             Self::Personal => "personal",
     29         }
     30     }
     31 }
     32 
     33 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
     34 #[serde(rename_all = "snake_case")]
     35 pub enum FarmerSection {
     36     #[default]
     37     Today,
     38     Products,
     39     Orders,
     40     PackDay,
     41     Farm,
     42 }
     43 
     44 impl FarmerSection {
     45     pub const fn storage_key(self) -> &'static str {
     46         match self {
     47             Self::Today => "farmer.today",
     48             Self::Products => "farmer.products",
     49             Self::Orders => "farmer.orders",
     50             Self::PackDay => "farmer.pack_day",
     51             Self::Farm => "farmer.farm",
     52         }
     53     }
     54 }
     55 
     56 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
     57 #[serde(rename_all = "snake_case")]
     58 pub enum PersonalSection {
     59     #[default]
     60     Browse,
     61     Search,
     62     Cart,
     63     Orders,
     64 }
     65 
     66 impl PersonalSection {
     67     pub const fn storage_key(self) -> &'static str {
     68         match self {
     69             Self::Browse => "personal.browse",
     70             Self::Search => "personal.search",
     71             Self::Cart => "personal.cart",
     72             Self::Orders => "personal.orders",
     73         }
     74     }
     75 }
     76 
     77 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
     78 #[serde(tag = "surface", content = "section", rename_all = "snake_case")]
     79 pub enum ShellSection {
     80     #[default]
     81     Home,
     82     Account,
     83     Personal(PersonalSection),
     84     Farmer(FarmerSection),
     85     Settings(SettingsSection),
     86 }
     87 
     88 impl ShellSection {
     89     pub const fn surface(self) -> Option<ActiveSurface> {
     90         match self {
     91             Self::Home | Self::Account | Self::Settings(_) => None,
     92             Self::Personal(_) => Some(ActiveSurface::Personal),
     93             Self::Farmer(_) => Some(ActiveSurface::Farmer),
     94         }
     95     }
     96 
     97     pub const fn default_for_surface(surface: ActiveSurface) -> Self {
     98         match surface {
     99             ActiveSurface::Personal => Self::Personal(PersonalSection::Browse),
    100             ActiveSurface::Farmer => Self::Farmer(FarmerSection::Today),
    101         }
    102     }
    103 
    104     pub const fn storage_key(self) -> &'static str {
    105         match self {
    106             Self::Home => "home",
    107             Self::Account => "account",
    108             Self::Personal(section) => section.storage_key(),
    109             Self::Farmer(section) => section.storage_key(),
    110             Self::Settings(section) => section.storage_key(),
    111         }
    112     }
    113 }
    114 
    115 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    116 pub struct ParseShellSectionError;
    117 
    118 impl fmt::Display for ParseShellSectionError {
    119     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    120         formatter.write_str("invalid shell section key")
    121     }
    122 }
    123 
    124 impl Error for ParseShellSectionError {}
    125 
    126 impl FromStr for ShellSection {
    127     type Err = ParseShellSectionError;
    128 
    129     fn from_str(value: &str) -> Result<Self, Self::Err> {
    130         match value {
    131             "home" => Ok(Self::Home),
    132             "account" => Ok(Self::Account),
    133             "personal.browse" => Ok(Self::Personal(PersonalSection::Browse)),
    134             "personal.search" => Ok(Self::Personal(PersonalSection::Search)),
    135             "personal.cart" => Ok(Self::Personal(PersonalSection::Cart)),
    136             "personal.orders" => Ok(Self::Personal(PersonalSection::Orders)),
    137             "farmer.today" => Ok(Self::Farmer(FarmerSection::Today)),
    138             "farmer.products" => Ok(Self::Farmer(FarmerSection::Products)),
    139             "farmer.orders" => Ok(Self::Farmer(FarmerSection::Orders)),
    140             "farmer.pack_day" => Ok(Self::Farmer(FarmerSection::PackDay)),
    141             "farmer.farm" => Ok(Self::Farmer(FarmerSection::Farm)),
    142             "settings.account" => Ok(Self::Settings(SettingsSection::Account)),
    143             "settings.farm" => Ok(Self::Settings(SettingsSection::Farm)),
    144             "settings.settings" => Ok(Self::Settings(SettingsSection::Settings)),
    145             "settings.about" => Ok(Self::Settings(SettingsSection::About)),
    146             _ => Err(ParseShellSectionError),
    147         }
    148     }
    149 }
    150 
    151 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    152 #[serde(rename_all = "snake_case")]
    153 pub enum IdentityBlockedReason {
    154     RuntimeUnavailable,
    155     HostVaultUnavailable,
    156 }
    157 
    158 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    159 #[serde(tag = "status", content = "reason", rename_all = "snake_case")]
    160 pub enum IdentityReadiness {
    161     #[default]
    162     MissingAccount,
    163     Ready,
    164     Blocked(IdentityBlockedReason),
    165 }
    166 
    167 impl IdentityReadiness {
    168     pub const fn storage_key(self) -> &'static str {
    169         match self {
    170             Self::MissingAccount => "missing_account",
    171             Self::Ready => "ready",
    172             Self::Blocked(IdentityBlockedReason::RuntimeUnavailable) => "runtime_unavailable",
    173             Self::Blocked(IdentityBlockedReason::HostVaultUnavailable) => "host_vault_unavailable",
    174         }
    175     }
    176 }
    177 
    178 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    179 pub struct SelectedSurfaceProjection {
    180     pub active_surface: ActiveSurface,
    181 }
    182 
    183 impl Default for SelectedSurfaceProjection {
    184     fn default() -> Self {
    185         Self::new(ActiveSurface::Personal)
    186     }
    187 }
    188 
    189 impl SelectedSurfaceProjection {
    190     pub const fn new(active_surface: ActiveSurface) -> Self {
    191         Self { active_surface }
    192     }
    193 }
    194 
    195 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    196 pub struct FarmerActivationProjection {
    197     pub farm_id: Option<FarmId>,
    198 }
    199 
    200 impl FarmerActivationProjection {
    201     pub const fn inactive() -> Self {
    202         Self { farm_id: None }
    203     }
    204 
    205     pub fn active(farm_id: FarmId) -> Self {
    206         Self {
    207             farm_id: Some(farm_id),
    208         }
    209     }
    210 
    211     pub const fn is_active(&self) -> bool {
    212         self.farm_id.is_some()
    213     }
    214 }
    215 
    216 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    217 pub struct AccountSummary {
    218     pub account_id: String,
    219     pub npub: String,
    220     pub label: Option<String>,
    221     pub custody: AccountCustody,
    222 }
    223 
    224 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    225 pub struct AccountSurfaceActivationProjection {
    226     pub account_id: String,
    227     pub selected_surface: SelectedSurfaceProjection,
    228     pub farmer_activation: FarmerActivationProjection,
    229 }
    230 
    231 impl AccountSurfaceActivationProjection {
    232     pub fn new(
    233         account_id: impl Into<String>,
    234         selected_surface: SelectedSurfaceProjection,
    235         farmer_activation: FarmerActivationProjection,
    236     ) -> Self {
    237         let active_surface = if farmer_activation.is_active() {
    238             selected_surface.active_surface
    239         } else {
    240             ActiveSurface::Personal
    241         };
    242 
    243         Self {
    244             account_id: account_id.into(),
    245             selected_surface: SelectedSurfaceProjection::new(active_surface),
    246             farmer_activation,
    247         }
    248     }
    249 
    250     pub const fn active_surface(&self) -> ActiveSurface {
    251         self.selected_surface.active_surface
    252     }
    253 }
    254 
    255 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    256 pub struct SelectedAccountProjection {
    257     pub account: AccountSummary,
    258     pub selected_surface: SelectedSurfaceProjection,
    259     pub farmer_activation: FarmerActivationProjection,
    260 }
    261 
    262 impl SelectedAccountProjection {
    263     pub fn new(
    264         account: AccountSummary,
    265         selected_surface: SelectedSurfaceProjection,
    266         farmer_activation: FarmerActivationProjection,
    267     ) -> Self {
    268         let active_surface = if farmer_activation.is_active() {
    269             selected_surface.active_surface
    270         } else {
    271             ActiveSurface::Personal
    272         };
    273 
    274         Self {
    275             account,
    276             selected_surface: SelectedSurfaceProjection::new(active_surface),
    277             farmer_activation,
    278         }
    279     }
    280 
    281     pub fn from_surface_activation(
    282         account: AccountSummary,
    283         activation: AccountSurfaceActivationProjection,
    284     ) -> Self {
    285         Self::new(
    286             account,
    287             activation.selected_surface,
    288             activation.farmer_activation,
    289         )
    290     }
    291 
    292     pub const fn active_surface(&self) -> ActiveSurface {
    293         self.selected_surface.active_surface
    294     }
    295 }
    296 
    297 impl From<&SelectedAccountProjection> for AccountSurfaceActivationProjection {
    298     fn from(value: &SelectedAccountProjection) -> Self {
    299         Self::new(
    300             value.account.account_id.clone(),
    301             value.selected_surface,
    302             value.farmer_activation.clone(),
    303         )
    304     }
    305 }
    306 
    307 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    308 #[serde(rename_all = "snake_case")]
    309 pub enum AppStartupGate {
    310     Blocked,
    311     #[default]
    312     SetupRequired,
    313     Personal,
    314     Farmer,
    315 }
    316 
    317 impl AppStartupGate {
    318     pub const fn storage_key(self) -> &'static str {
    319         match self {
    320             Self::Blocked => "blocked",
    321             Self::SetupRequired => "setup_required",
    322             Self::Personal => "personal",
    323             Self::Farmer => "farmer",
    324         }
    325     }
    326 }
    327 
    328 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    329 #[serde(rename_all = "snake_case")]
    330 pub enum LoggedOutStartupPhase {
    331     #[default]
    332     ContinuePrompt,
    333     IdentityChoice,
    334     GenerateKeyStarting,
    335     SignerEntry,
    336 }
    337 
    338 impl LoggedOutStartupPhase {
    339     pub const fn storage_key(self) -> &'static str {
    340         match self {
    341             Self::ContinuePrompt => "continue_prompt",
    342             Self::IdentityChoice => "identity_choice",
    343             Self::GenerateKeyStarting => "generate_key_starting",
    344             Self::SignerEntry => "signer_entry",
    345         }
    346     }
    347 }
    348 
    349 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    350 #[serde(rename_all = "snake_case")]
    351 pub enum StartupSignerSourceKind {
    352     BunkerUri,
    353     DiscoveryUrl,
    354 }
    355 
    356 impl StartupSignerSourceKind {
    357     pub const fn storage_key(self) -> &'static str {
    358         match self {
    359             Self::BunkerUri => "bunker_uri",
    360             Self::DiscoveryUrl => "discovery_url",
    361         }
    362     }
    363 }
    364 
    365 #[derive(Clone, Debug, Eq, PartialEq)]
    366 pub enum ParseStartupSignerSourceError {
    367     EmptyInput,
    368     UnsupportedClientUri,
    369     UnsupportedSource,
    370     MissingDiscoveryUri,
    371 }
    372 
    373 impl fmt::Display for ParseStartupSignerSourceError {
    374     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    375         match self {
    376             Self::EmptyInput => formatter.write_str("signer source input must not be empty"),
    377             Self::UnsupportedClientUri => formatter.write_str(
    378                 "client nostrconnect URIs are not accepted by the app signer entry flow",
    379             ),
    380             Self::UnsupportedSource => {
    381                 formatter.write_str("signer source input must be a bunker URI or discovery URL")
    382             }
    383             Self::MissingDiscoveryUri => {
    384                 formatter.write_str("discovery URL must include a non-empty uri query parameter")
    385             }
    386         }
    387     }
    388 }
    389 
    390 impl Error for ParseStartupSignerSourceError {}
    391 
    392 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    393 #[serde(tag = "kind", content = "value", rename_all = "snake_case")]
    394 pub enum StartupSignerSource {
    395     BunkerUri(String),
    396     DiscoveryUrl(String),
    397 }
    398 
    399 impl StartupSignerSource {
    400     pub const fn kind(&self) -> StartupSignerSourceKind {
    401         match self {
    402             Self::BunkerUri(_) => StartupSignerSourceKind::BunkerUri,
    403             Self::DiscoveryUrl(_) => StartupSignerSourceKind::DiscoveryUrl,
    404         }
    405     }
    406 
    407     pub fn value(&self) -> &str {
    408         match self {
    409             Self::BunkerUri(value) | Self::DiscoveryUrl(value) => value,
    410         }
    411     }
    412 }
    413 
    414 impl FromStr for StartupSignerSource {
    415     type Err = ParseStartupSignerSourceError;
    416 
    417     fn from_str(value: &str) -> Result<Self, Self::Err> {
    418         let trimmed = value.trim();
    419         if trimmed.is_empty() {
    420             return Err(ParseStartupSignerSourceError::EmptyInput);
    421         }
    422 
    423         if trimmed.starts_with("nostrconnect://") {
    424             return Err(ParseStartupSignerSourceError::UnsupportedClientUri);
    425         }
    426 
    427         if trimmed.starts_with("bunker://") {
    428             return Ok(Self::BunkerUri(trimmed.to_owned()));
    429         }
    430 
    431         let url =
    432             Url::parse(trimmed).map_err(|_| ParseStartupSignerSourceError::UnsupportedSource)?;
    433         let has_discovery_uri = url
    434             .query_pairs()
    435             .any(|(key, value)| key == "uri" && !value.trim().is_empty());
    436 
    437         if !has_discovery_uri {
    438             return Err(ParseStartupSignerSourceError::MissingDiscoveryUri);
    439         }
    440 
    441         Ok(Self::DiscoveryUrl(trimmed.to_owned()))
    442     }
    443 }
    444 
    445 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    446 pub struct StartupSignerEntryProjection {
    447     pub source_input: String,
    448 }
    449 
    450 impl StartupSignerEntryProjection {
    451     pub fn new(source_input: impl Into<String>) -> Self {
    452         Self {
    453             source_input: source_input.into(),
    454         }
    455     }
    456 
    457     pub fn parsed_source(&self) -> Result<StartupSignerSource, ParseStartupSignerSourceError> {
    458         self.source_input.parse()
    459     }
    460 
    461     pub fn set_source_input(&mut self, source_input: impl Into<String>) {
    462         self.source_input = source_input.into();
    463     }
    464 }
    465 
    466 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    467 pub struct LoggedOutStartupProjection {
    468     pub phase: LoggedOutStartupPhase,
    469     pub signer_entry: StartupSignerEntryProjection,
    470 }
    471 
    472 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    473 #[serde(tag = "kind", content = "account_id", rename_all = "snake_case")]
    474 pub enum BuyerContext {
    475     #[default]
    476     Guest,
    477     Account(String),
    478 }
    479 
    480 impl BuyerContext {
    481     pub const fn guest() -> Self {
    482         Self::Guest
    483     }
    484 
    485     pub fn account(account_id: impl Into<String>) -> Self {
    486         Self::Account(account_id.into())
    487     }
    488 
    489     pub fn storage_key(&self) -> String {
    490         match self {
    491             Self::Guest => "guest".to_owned(),
    492             Self::Account(account_id) => format!("account:{account_id}"),
    493         }
    494     }
    495 }
    496 
    497 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    498 #[serde(rename_all = "snake_case")]
    499 pub enum PersonalEntryState {
    500     Blocked,
    501     #[default]
    502     Guest,
    503     SignedIn,
    504 }
    505 
    506 impl PersonalEntryState {
    507     pub const fn storage_key(self) -> &'static str {
    508         match self {
    509             Self::Blocked => "blocked",
    510             Self::Guest => "guest",
    511             Self::SignedIn => "signed_in",
    512         }
    513     }
    514 }
    515 
    516 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    517 pub struct PersonalEntryProjection {
    518     pub state: PersonalEntryState,
    519     pub selected_account: Option<SelectedAccountProjection>,
    520     pub can_enter_farmer_workspace: bool,
    521 }
    522 
    523 impl PersonalEntryProjection {
    524     pub fn blocked(selected_account: Option<SelectedAccountProjection>) -> Self {
    525         let can_enter_farmer_workspace = selected_account
    526             .as_ref()
    527             .is_some_and(|account| account.farmer_activation.is_active());
    528 
    529         Self {
    530             state: PersonalEntryState::Blocked,
    531             selected_account,
    532             can_enter_farmer_workspace,
    533         }
    534     }
    535 
    536     pub const fn guest() -> Self {
    537         Self {
    538             state: PersonalEntryState::Guest,
    539             selected_account: None,
    540             can_enter_farmer_workspace: false,
    541         }
    542     }
    543 
    544     pub fn signed_in(selected_account: SelectedAccountProjection) -> Self {
    545         let can_enter_farmer_workspace = selected_account.farmer_activation.is_active();
    546 
    547         Self {
    548             state: PersonalEntryState::SignedIn,
    549             selected_account: Some(selected_account),
    550             can_enter_farmer_workspace,
    551         }
    552     }
    553 }
    554 
    555 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    556 pub struct AppIdentityProjection {
    557     pub readiness: IdentityReadiness,
    558     pub roster: Vec<AccountSummary>,
    559     pub selected_account: Option<SelectedAccountProjection>,
    560 }
    561 
    562 impl AppIdentityProjection {
    563     pub fn missing() -> Self {
    564         Self::with_readiness(IdentityReadiness::MissingAccount, Vec::new(), None)
    565     }
    566 
    567     pub fn missing_with_roster(roster: Vec<AccountSummary>) -> Self {
    568         Self::with_readiness(IdentityReadiness::MissingAccount, roster, None)
    569     }
    570 
    571     pub fn blocked(reason: IdentityBlockedReason) -> Self {
    572         Self::with_readiness(IdentityReadiness::Blocked(reason), Vec::new(), None)
    573     }
    574 
    575     pub fn blocked_with_selection(
    576         reason: IdentityBlockedReason,
    577         roster: Vec<AccountSummary>,
    578         selected_account: Option<SelectedAccountProjection>,
    579     ) -> Self {
    580         Self::with_readiness(IdentityReadiness::Blocked(reason), roster, selected_account)
    581     }
    582 
    583     pub fn ready(roster: Vec<AccountSummary>, selected_account: SelectedAccountProjection) -> Self {
    584         Self::with_readiness(IdentityReadiness::Ready, roster, Some(selected_account))
    585     }
    586 
    587     pub fn with_readiness(
    588         readiness: IdentityReadiness,
    589         mut roster: Vec<AccountSummary>,
    590         selected_account: Option<SelectedAccountProjection>,
    591     ) -> Self {
    592         if let Some(selected_account) = selected_account.as_ref()
    593             && !roster
    594                 .iter()
    595                 .any(|account| account.account_id == selected_account.account.account_id)
    596         {
    597             roster.insert(0, selected_account.account.clone());
    598         }
    599 
    600         Self {
    601             readiness,
    602             roster,
    603             selected_account,
    604         }
    605     }
    606 
    607     pub fn startup_gate(&self) -> AppStartupGate {
    608         match self.readiness {
    609             IdentityReadiness::MissingAccount => AppStartupGate::SetupRequired,
    610             IdentityReadiness::Blocked(_) => AppStartupGate::Blocked,
    611             IdentityReadiness::Ready => self
    612                 .selected_account
    613                 .as_ref()
    614                 .map(|account| {
    615                     if account.farmer_activation.is_active()
    616                         && account.active_surface() == ActiveSurface::Farmer
    617                     {
    618                         AppStartupGate::Farmer
    619                     } else {
    620                         AppStartupGate::Personal
    621                     }
    622                 })
    623                 .unwrap_or(AppStartupGate::SetupRequired),
    624         }
    625     }
    626 
    627     pub fn settings_account(&self) -> SettingsAccountProjection {
    628         self.into()
    629     }
    630 
    631     pub fn personal_entry(&self) -> PersonalEntryProjection {
    632         match self.readiness {
    633             IdentityReadiness::MissingAccount => PersonalEntryProjection::guest(),
    634             IdentityReadiness::Blocked(_) => {
    635                 PersonalEntryProjection::blocked(self.selected_account.clone())
    636             }
    637             IdentityReadiness::Ready => self
    638                 .selected_account
    639                 .clone()
    640                 .map(PersonalEntryProjection::signed_in)
    641                 .unwrap_or_else(PersonalEntryProjection::guest),
    642         }
    643     }
    644 
    645     pub fn buyer_context(&self) -> BuyerContext {
    646         self.selected_account
    647             .as_ref()
    648             .map(|account| BuyerContext::account(account.account.account_id.clone()))
    649             .unwrap_or_default()
    650     }
    651 }
    652 
    653 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    654 pub struct SettingsAccountProjection {
    655     pub readiness: IdentityReadiness,
    656     pub roster: Vec<AccountSummary>,
    657     pub selected_account: Option<SelectedAccountProjection>,
    658 }
    659 
    660 impl From<&AppIdentityProjection> for SettingsAccountProjection {
    661     fn from(value: &AppIdentityProjection) -> Self {
    662         Self {
    663             readiness: value.readiness,
    664             roster: value.roster.clone(),
    665             selected_account: value.selected_account.clone(),
    666         }
    667     }
    668 }
    669 
    670 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    671 pub struct FarmRulesProjection {
    672     pub farm_profile: Option<FarmProfileRecord>,
    673     pub pickup_locations: Vec<PickupLocationRecord>,
    674     pub operating_rules: Option<FarmOperatingRulesRecord>,
    675     pub fulfillment_windows: Vec<FulfillmentWindowRecord>,
    676     pub blackout_periods: Vec<BlackoutPeriodRecord>,
    677     pub readiness: FarmRulesReadiness,
    678 }
    679 
    680 impl Default for FarmRulesProjection {
    681     fn default() -> Self {
    682         Self {
    683             farm_profile: None,
    684             pickup_locations: Vec::new(),
    685             operating_rules: None,
    686             fulfillment_windows: Vec::new(),
    687             blackout_periods: Vec::new(),
    688             readiness: FarmRulesReadiness::missing_v1_basics(),
    689         }
    690     }
    691 }
    692 
    693 impl FarmRulesProjection {
    694     pub fn is_ready(&self) -> bool {
    695         self.readiness.is_ready()
    696     }
    697 }
    698 
    699 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    700 #[serde(rename_all = "snake_case")]
    701 pub enum ProductsFilter {
    702     #[default]
    703     All,
    704     Live,
    705     Drafts,
    706     NeedAttention,
    707     Paused,
    708     Archived,
    709 }
    710 
    711 impl ProductsFilter {
    712     pub const fn storage_key(self) -> &'static str {
    713         match self {
    714             Self::All => "all",
    715             Self::Live => "live",
    716             Self::Drafts => "drafts",
    717             Self::NeedAttention => "need_attention",
    718             Self::Paused => "paused",
    719             Self::Archived => "archived",
    720         }
    721     }
    722 }
    723 
    724 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    725 #[serde(rename_all = "snake_case")]
    726 pub enum ProductsSort {
    727     #[default]
    728     Updated,
    729     Name,
    730     Availability,
    731     Stock,
    732     Price,
    733 }
    734 
    735 impl ProductsSort {
    736     pub const fn storage_key(self) -> &'static str {
    737         match self {
    738             Self::Updated => "updated",
    739             Self::Name => "name",
    740             Self::Availability => "availability",
    741             Self::Stock => "stock",
    742             Self::Price => "price",
    743         }
    744     }
    745 }
    746 
    747 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    748 #[serde(rename_all = "snake_case")]
    749 pub enum ProductAttentionState {
    750     #[default]
    751     Healthy,
    752     LowStock,
    753     SoldOut,
    754     MissingAvailability,
    755     NoFutureAvailability,
    756     MissingDetails,
    757 }
    758 
    759 impl ProductAttentionState {
    760     pub const fn storage_key(self) -> &'static str {
    761         match self {
    762             Self::Healthy => "healthy",
    763             Self::LowStock => "low_stock",
    764             Self::SoldOut => "sold_out",
    765             Self::MissingAvailability => "missing_availability",
    766             Self::NoFutureAvailability => "no_future_availability",
    767             Self::MissingDetails => "missing_details",
    768         }
    769     }
    770 
    771     pub const fn requires_attention(self) -> bool {
    772         !matches!(self, Self::Healthy)
    773     }
    774 }
    775 
    776 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    777 #[serde(rename_all = "snake_case")]
    778 pub enum ProductAvailabilityState {
    779     Scheduled,
    780     Open,
    781     MissingWindow,
    782     NoFutureWindow,
    783 }
    784 
    785 impl ProductAvailabilityState {
    786     pub const fn storage_key(self) -> &'static str {
    787         match self {
    788             Self::Scheduled => "scheduled",
    789             Self::Open => "open",
    790             Self::MissingWindow => "missing_window",
    791             Self::NoFutureWindow => "no_future_window",
    792         }
    793     }
    794 }
    795 
    796 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    797 pub struct ProductAvailabilitySummary {
    798     pub state: ProductAvailabilityState,
    799     pub label: String,
    800 }
    801 
    802 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    803 #[serde(rename_all = "snake_case")]
    804 pub enum ProductStockState {
    805     Unset,
    806     InStock,
    807     LowStock,
    808     SoldOut,
    809 }
    810 
    811 impl ProductStockState {
    812     pub const fn storage_key(self) -> &'static str {
    813         match self {
    814             Self::Unset => "unset",
    815             Self::InStock => "in_stock",
    816             Self::LowStock => "low_stock",
    817             Self::SoldOut => "sold_out",
    818         }
    819     }
    820 
    821     pub const fn requires_attention(self) -> bool {
    822         matches!(self, Self::LowStock | Self::SoldOut)
    823     }
    824 }
    825 
    826 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    827 pub struct ProductStockSummary {
    828     pub quantity: Option<u32>,
    829     pub unit_label: Option<String>,
    830     pub state: ProductStockState,
    831 }
    832 
    833 impl ProductStockSummary {
    834     pub const fn requires_attention(&self) -> bool {
    835         self.state.requires_attention()
    836     }
    837 }
    838 
    839 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    840 pub struct ProductPricePresentation {
    841     pub amount_minor_units: u32,
    842     pub currency_code: String,
    843     pub unit_label: String,
    844 }
    845 
    846 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    847 pub struct ProductsListSummary {
    848     pub total_products: u32,
    849     pub live_products: u32,
    850     pub draft_products: u32,
    851     pub need_attention_products: u32,
    852 }
    853 
    854 impl ProductsListSummary {
    855     pub const fn has_products(&self) -> bool {
    856         self.total_products > 0
    857     }
    858 }
    859 
    860 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    861 pub struct ProductsListRow {
    862     pub product_id: ProductId,
    863     pub farm_id: FarmId,
    864     pub title: String,
    865     pub subtitle: Option<String>,
    866     pub status: ProductStatus,
    867     pub attention_state: ProductAttentionState,
    868     pub availability: ProductAvailabilitySummary,
    869     pub stock: ProductStockSummary,
    870     pub price: Option<ProductPricePresentation>,
    871     pub updated_at: String,
    872 }
    873 
    874 impl ProductsListRow {
    875     pub const fn requires_attention(&self) -> bool {
    876         self.attention_state.requires_attention() || self.stock.requires_attention()
    877     }
    878 }
    879 
    880 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    881 pub struct ProductsListProjection {
    882     pub summary: ProductsListSummary,
    883     pub rows: Vec<ProductsListRow>,
    884 }
    885 
    886 impl ProductsListProjection {
    887     pub fn is_empty(&self) -> bool {
    888         self.rows.is_empty()
    889     }
    890 }
    891 
    892 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    893 pub struct ProductEditorDraft {
    894     pub title: String,
    895     pub subtitle: String,
    896     pub category: String,
    897     pub unit_label: String,
    898     pub price_minor_units: Option<u32>,
    899     pub price_currency: String,
    900     pub stock_quantity: Option<u32>,
    901     pub availability_window_id: Option<FulfillmentWindowId>,
    902     pub status: ProductStatus,
    903 }
    904 
    905 impl Default for ProductEditorDraft {
    906     fn default() -> Self {
    907         Self {
    908             title: String::new(),
    909             subtitle: String::new(),
    910             category: String::new(),
    911             unit_label: String::new(),
    912             price_minor_units: None,
    913             price_currency: "USD".to_owned(),
    914             stock_quantity: None,
    915             availability_window_id: None,
    916             status: ProductStatus::Draft,
    917         }
    918     }
    919 }
    920 
    921 impl ProductEditorDraft {
    922     pub fn publish_blockers(&self) -> Vec<ProductPublishBlocker> {
    923         let mut blockers = Vec::new();
    924 
    925         if self.title.trim().is_empty() {
    926             blockers.push(ProductPublishBlocker::AddProductName);
    927         }
    928 
    929         if self.category.trim().is_empty() {
    930             blockers.push(ProductPublishBlocker::ChooseCategory);
    931         }
    932 
    933         if self.unit_label.trim().is_empty() {
    934             blockers.push(ProductPublishBlocker::ChooseUnit);
    935         }
    936 
    937         if self.price_minor_units.is_none_or(|value| value == 0) {
    938             blockers.push(ProductPublishBlocker::SetPrice);
    939         }
    940 
    941         if self.stock_quantity.is_none() {
    942             blockers.push(ProductPublishBlocker::SetStock);
    943         }
    944 
    945         if self.availability_window_id.is_none() {
    946             blockers.push(ProductPublishBlocker::AttachAvailability);
    947         }
    948 
    949         blockers
    950     }
    951 
    952     pub fn is_publish_ready(&self) -> bool {
    953         self.publish_blockers().is_empty()
    954     }
    955 }
    956 
    957 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    958 pub struct BuyerListingRow {
    959     pub product_id: ProductId,
    960     pub farm_id: FarmId,
    961     pub farm_display_name: String,
    962     pub listing_relays: Vec<String>,
    963     pub title: String,
    964     pub subtitle: Option<String>,
    965     pub price: ProductPricePresentation,
    966     pub availability: ProductAvailabilitySummary,
    967     pub stock: ProductStockSummary,
    968     pub fulfillment_methods: BTreeSet<FarmOrderMethod>,
    969     pub next_fulfillment_window_label: Option<String>,
    970 }
    971 
    972 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    973 pub struct BuyerListingsProjection {
    974     pub rows: Vec<BuyerListingRow>,
    975 }
    976 
    977 impl BuyerListingsProjection {
    978     pub fn is_empty(&self) -> bool {
    979         self.rows.is_empty()
    980     }
    981 }
    982 
    983 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    984 pub struct BuyerProductDetailProjection {
    985     pub listing: BuyerListingRow,
    986     pub detail_text: Option<String>,
    987     pub selected_quantity: u32,
    988 }
    989 
    990 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    991 pub struct BuyerCartLineProjection {
    992     pub product_id: ProductId,
    993     pub farm_id: FarmId,
    994     pub farm_display_name: String,
    995     pub title: String,
    996     pub quantity: u32,
    997     pub unit_price: ProductPricePresentation,
    998     pub line_total_minor_units: u32,
    999     pub fulfillment_summary: String,
   1000 }
   1001 
   1002 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1003 pub struct BuyerCartReplaceConfirmationProjection {
   1004     pub current_farm_display_name: String,
   1005     pub incoming_farm_display_name: String,
   1006 }
   1007 
   1008 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1009 pub struct BuyerCartProjection {
   1010     pub farm_id: Option<FarmId>,
   1011     pub farm_display_name: Option<String>,
   1012     pub lines: Vec<BuyerCartLineProjection>,
   1013     pub subtotal_minor_units: Option<u32>,
   1014     pub currency_code: Option<String>,
   1015     pub replace_confirmation: Option<BuyerCartReplaceConfirmationProjection>,
   1016 }
   1017 
   1018 impl BuyerCartProjection {
   1019     pub fn is_empty(&self) -> bool {
   1020         self.lines.is_empty()
   1021     }
   1022 }
   1023 
   1024 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1025 pub struct BuyerOrderReviewDraft {
   1026     pub name: String,
   1027     pub email: String,
   1028     pub phone: String,
   1029     pub order_note: String,
   1030 }
   1031 
   1032 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1033 pub struct BuyerOrderReviewSummaryProjection {
   1034     pub farm_display_name: Option<String>,
   1035     pub fulfillment_summary: Option<String>,
   1036     pub line_count: u32,
   1037     pub subtotal_minor_units: Option<u32>,
   1038     pub currency_code: Option<String>,
   1039 }
   1040 
   1041 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1042 #[serde(rename_all = "snake_case")]
   1043 pub enum BuyerOrderReviewDisabledReason {
   1044     EmptyCart,
   1045     MissingFulfillment,
   1046     MissingName,
   1047     MissingEmail,
   1048     AccountRequired,
   1049 }
   1050 
   1051 impl BuyerOrderReviewDisabledReason {
   1052     pub const fn storage_key(self) -> &'static str {
   1053         match self {
   1054             Self::EmptyCart => "empty_cart",
   1055             Self::MissingFulfillment => "missing_fulfillment",
   1056             Self::MissingName => "missing_name",
   1057             Self::MissingEmail => "missing_email",
   1058             Self::AccountRequired => "account_required",
   1059         }
   1060     }
   1061 }
   1062 
   1063 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1064 pub struct BuyerOrderReviewProjection {
   1065     pub draft: BuyerOrderReviewDraft,
   1066     pub summary: BuyerOrderReviewSummaryProjection,
   1067     pub can_place_order: bool,
   1068     pub place_order_disabled_reason: Option<BuyerOrderReviewDisabledReason>,
   1069 }
   1070 
   1071 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1072 #[serde(rename_all = "snake_case")]
   1073 pub enum TradeAgreementStatus {
   1074     #[default]
   1075     Ordered,
   1076     Confirmed,
   1077     Declined,
   1078     Cancelled,
   1079     NeedsReview,
   1080 }
   1081 
   1082 impl TradeAgreementStatus {
   1083     pub const fn storage_key(self) -> &'static str {
   1084         match self {
   1085             Self::Ordered => "ordered",
   1086             Self::Confirmed => "confirmed",
   1087             Self::Declined => "declined",
   1088             Self::Cancelled => "cancelled",
   1089             Self::NeedsReview => "needs_review",
   1090         }
   1091     }
   1092 
   1093     pub const fn label_key_id(self) -> &'static str {
   1094         match self {
   1095             Self::Ordered => "messages.trade.workflow.agreement.ordered",
   1096             Self::Confirmed => "messages.trade.workflow.agreement.confirmed",
   1097             Self::Declined => "messages.trade.workflow.agreement.declined",
   1098             Self::Cancelled => "messages.trade.workflow.agreement.cancelled",
   1099             Self::NeedsReview => "messages.trade.workflow.agreement.needs_review",
   1100         }
   1101     }
   1102 
   1103     pub const fn from_active_order_status(status: &RadrootsOrderStatus) -> Self {
   1104         match status {
   1105             RadrootsOrderStatus::Missing => Self::NeedsReview,
   1106             RadrootsOrderStatus::Requested => Self::Ordered,
   1107             RadrootsOrderStatus::Accepted => Self::Confirmed,
   1108             RadrootsOrderStatus::Declined => Self::Declined,
   1109             RadrootsOrderStatus::Cancelled => Self::Cancelled,
   1110             RadrootsOrderStatus::Invalid => Self::NeedsReview,
   1111         }
   1112     }
   1113 }
   1114 
   1115 impl From<&RadrootsOrderStatus> for TradeAgreementStatus {
   1116     fn from(status: &RadrootsOrderStatus) -> Self {
   1117         Self::from_active_order_status(status)
   1118     }
   1119 }
   1120 
   1121 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1122 #[serde(rename_all = "snake_case")]
   1123 pub enum TradeRevisionStatus {
   1124     #[default]
   1125     None,
   1126     ChangeProposed,
   1127     Updated,
   1128     KeptAsPlaced,
   1129 }
   1130 
   1131 #[derive(Clone, Debug, Eq, PartialEq)]
   1132 pub struct ParseTradeRevisionStatusError {
   1133     value: String,
   1134 }
   1135 
   1136 impl ParseTradeRevisionStatusError {
   1137     pub fn value(&self) -> &str {
   1138         self.value.as_str()
   1139     }
   1140 }
   1141 
   1142 impl fmt::Display for ParseTradeRevisionStatusError {
   1143     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
   1144         write!(formatter, "invalid trade revision status `{}`", self.value)
   1145     }
   1146 }
   1147 
   1148 impl Error for ParseTradeRevisionStatusError {}
   1149 
   1150 impl TradeRevisionStatus {
   1151     pub const fn storage_key(self) -> &'static str {
   1152         match self {
   1153             Self::None => "none",
   1154             Self::ChangeProposed => "change_proposed",
   1155             Self::Updated => "updated",
   1156             Self::KeptAsPlaced => "kept_as_placed",
   1157         }
   1158     }
   1159 
   1160     pub const fn label_key_id(self) -> &'static str {
   1161         match self {
   1162             Self::None => "messages.trade.workflow.revision.none",
   1163             Self::ChangeProposed => "messages.trade.workflow.revision.change_proposed",
   1164             Self::Updated => "messages.trade.workflow.revision.updated",
   1165             Self::KeptAsPlaced => "messages.trade.workflow.revision.kept_as_placed",
   1166         }
   1167     }
   1168 
   1169     pub fn try_from_storage_key(value: &str) -> Result<Self, ParseTradeRevisionStatusError> {
   1170         match value {
   1171             "none" => Ok(Self::None),
   1172             "change_proposed" => Ok(Self::ChangeProposed),
   1173             "updated" => Ok(Self::Updated),
   1174             "kept_as_placed" => Ok(Self::KeptAsPlaced),
   1175             _ => Err(ParseTradeRevisionStatusError {
   1176                 value: value.to_owned(),
   1177             }),
   1178         }
   1179     }
   1180 }
   1181 
   1182 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1183 #[serde(rename_all = "snake_case")]
   1184 pub enum TradeInventoryStatus {
   1185     Available,
   1186     Reserved,
   1187     SoldOut,
   1188     #[default]
   1189     NeedsReview,
   1190 }
   1191 
   1192 impl TradeInventoryStatus {
   1193     pub const fn storage_key(self) -> &'static str {
   1194         match self {
   1195             Self::Available => "available",
   1196             Self::Reserved => "reserved",
   1197             Self::SoldOut => "sold_out",
   1198             Self::NeedsReview => "needs_review",
   1199         }
   1200     }
   1201 
   1202     pub const fn label_key_id(self) -> &'static str {
   1203         match self {
   1204             Self::Available => "messages.trade.workflow.inventory.available",
   1205             Self::Reserved => "messages.trade.workflow.inventory.reserved",
   1206             Self::SoldOut => "messages.trade.workflow.inventory.sold_out",
   1207             Self::NeedsReview => "messages.trade.workflow.inventory.needs_review",
   1208         }
   1209     }
   1210 
   1211     pub fn from_active_order_projection(projection: &RadrootsOrderProjection) -> Self {
   1212         match projection.status {
   1213             RadrootsOrderStatus::Requested => Self::NeedsReview,
   1214             RadrootsOrderStatus::Accepted => Self::Reserved,
   1215             RadrootsOrderStatus::Declined | RadrootsOrderStatus::Cancelled => Self::Available,
   1216             RadrootsOrderStatus::Missing | RadrootsOrderStatus::Invalid => Self::NeedsReview,
   1217         }
   1218     }
   1219 }
   1220 
   1221 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
   1222 #[serde(rename_all = "snake_case")]
   1223 pub enum TradeWorkflowSource {
   1224     App,
   1225     Cli,
   1226     Relay,
   1227     LocalEvents,
   1228     #[default]
   1229     Unknown,
   1230 }
   1231 
   1232 impl TradeWorkflowSource {
   1233     pub const fn storage_key(self) -> &'static str {
   1234         match self {
   1235             Self::App => "app",
   1236             Self::Cli => "cli",
   1237             Self::Relay => "relay",
   1238             Self::LocalEvents => "local_events",
   1239             Self::Unknown => "unknown",
   1240         }
   1241     }
   1242 
   1243     pub const fn label_key_id(self) -> &'static str {
   1244         match self {
   1245             Self::App => "messages.trade.workflow.provenance.app",
   1246             Self::Cli => "messages.trade.workflow.provenance.cli",
   1247             Self::Relay => "messages.trade.workflow.provenance.relay",
   1248             Self::LocalEvents => "messages.trade.workflow.provenance.local_events",
   1249             Self::Unknown => "messages.trade.workflow.provenance.unknown",
   1250         }
   1251     }
   1252 }
   1253 
   1254 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1255 pub struct TradeEconomicsProjection {
   1256     pub subtotal_minor_units: Option<u32>,
   1257     pub discount_total_minor_units: Option<u32>,
   1258     pub adjustment_total_minor_units: Option<u32>,
   1259     pub total_minor_units: Option<u32>,
   1260     pub currency_code: Option<String>,
   1261 }
   1262 
   1263 impl TradeEconomicsProjection {
   1264     pub fn from_trade_order_economics(economics: &RadrootsOrderEconomics) -> Self {
   1265         Self {
   1266             subtotal_minor_units: money_minor_units(&economics.subtotal),
   1267             discount_total_minor_units: money_minor_units(&economics.discount_total),
   1268             adjustment_total_minor_units: money_minor_units(&economics.adjustment_total),
   1269             total_minor_units: money_minor_units(&economics.total),
   1270             currency_code: Some(economics.currency.to_string()),
   1271         }
   1272     }
   1273 }
   1274 
   1275 impl From<&RadrootsOrderEconomics> for TradeEconomicsProjection {
   1276     fn from(economics: &RadrootsOrderEconomics) -> Self {
   1277         Self::from_trade_order_economics(economics)
   1278     }
   1279 }
   1280 
   1281 fn money_minor_units(money: &RadrootsCoreMoney) -> Option<u32> {
   1282     money.to_minor_units_u32_exact().ok()
   1283 }
   1284 
   1285 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1286 pub struct TradeProvenanceProjection {
   1287     pub primary_source: TradeWorkflowSource,
   1288     pub sources: BTreeSet<TradeWorkflowSource>,
   1289     pub last_event_id: Option<String>,
   1290 }
   1291 
   1292 impl TradeProvenanceProjection {
   1293     pub fn new(
   1294         primary_source: TradeWorkflowSource,
   1295         sources: impl IntoIterator<Item = TradeWorkflowSource>,
   1296     ) -> Self {
   1297         let mut sources = sources.into_iter().collect::<BTreeSet<_>>();
   1298         sources.insert(primary_source);
   1299         Self {
   1300             primary_source,
   1301             sources,
   1302             last_event_id: None,
   1303         }
   1304     }
   1305 
   1306     pub fn from_primary_source(primary_source: TradeWorkflowSource) -> Self {
   1307         Self::new(primary_source, [primary_source])
   1308     }
   1309 
   1310     pub fn with_last_event_id(mut self, last_event_id: Option<String>) -> Self {
   1311         self.last_event_id = last_event_id;
   1312         self
   1313     }
   1314 }
   1315 
   1316 impl Default for TradeProvenanceProjection {
   1317     fn default() -> Self {
   1318         Self::from_primary_source(TradeWorkflowSource::Unknown)
   1319     }
   1320 }
   1321 
   1322 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1323 #[serde(rename_all = "snake_case")]
   1324 pub enum TradeValidationReceiptResult {
   1325     #[default]
   1326     Valid,
   1327     NeedsReview,
   1328 }
   1329 
   1330 impl TradeValidationReceiptResult {
   1331     pub const fn storage_key(self) -> &'static str {
   1332         match self {
   1333             Self::Valid => "valid",
   1334             Self::NeedsReview => "needs_review",
   1335         }
   1336     }
   1337 
   1338     pub const fn label_key_id(self) -> &'static str {
   1339         match self {
   1340             Self::Valid => "messages.trade.validation.result.valid",
   1341             Self::NeedsReview => "messages.trade.validation.result.needs_review",
   1342         }
   1343     }
   1344 
   1345     pub const fn from_validation_receipt_result(result: RadrootsValidationReceiptResult) -> Self {
   1346         match result {
   1347             RadrootsValidationReceiptResult::Valid => Self::Valid,
   1348             RadrootsValidationReceiptResult::Invalid => Self::NeedsReview,
   1349         }
   1350     }
   1351 }
   1352 
   1353 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1354 #[serde(rename_all = "snake_case")]
   1355 pub enum TradeValidationReceiptType {
   1356     ListingValidation,
   1357     #[default]
   1358     TradeTransition,
   1359     InventoryState,
   1360     StateCheckpoint,
   1361 }
   1362 
   1363 impl TradeValidationReceiptType {
   1364     pub const fn storage_key(self) -> &'static str {
   1365         match self {
   1366             Self::ListingValidation => "listing_validation",
   1367             Self::TradeTransition => "trade_transition",
   1368             Self::InventoryState => "inventory_state",
   1369             Self::StateCheckpoint => "state_checkpoint",
   1370         }
   1371     }
   1372 
   1373     pub const fn label_key_id(self) -> &'static str {
   1374         match self {
   1375             Self::ListingValidation => "messages.trade.validation.type.listing_validation",
   1376             Self::TradeTransition => "messages.trade.validation.type.trade_transition",
   1377             Self::InventoryState => "messages.trade.validation.type.inventory_state",
   1378             Self::StateCheckpoint => "messages.trade.validation.type.state_checkpoint",
   1379         }
   1380     }
   1381 
   1382     pub const fn from_validation_receipt_type(receipt_type: RadrootsValidationReceiptType) -> Self {
   1383         match receipt_type {
   1384             RadrootsValidationReceiptType::ListingValidation => Self::ListingValidation,
   1385             RadrootsValidationReceiptType::TradeTransition => Self::TradeTransition,
   1386             RadrootsValidationReceiptType::InventoryState => Self::InventoryState,
   1387             RadrootsValidationReceiptType::StateCheckpoint => Self::StateCheckpoint,
   1388         }
   1389     }
   1390 }
   1391 
   1392 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1393 #[serde(rename_all = "snake_case")]
   1394 pub enum TradeValidationReceiptProofSystem {
   1395     #[default]
   1396     None,
   1397     Sp1Core,
   1398     Sp1Compressed,
   1399     Sp1Groth16,
   1400     Sp1Plonk,
   1401 }
   1402 
   1403 impl TradeValidationReceiptProofSystem {
   1404     pub const fn storage_key(self) -> &'static str {
   1405         match self {
   1406             Self::None => "none",
   1407             Self::Sp1Core => "sp1_core",
   1408             Self::Sp1Compressed => "sp1_compressed",
   1409             Self::Sp1Groth16 => "sp1_groth16",
   1410             Self::Sp1Plonk => "sp1_plonk",
   1411         }
   1412     }
   1413 
   1414     pub const fn label_key_id(self) -> &'static str {
   1415         match self {
   1416             Self::None => "messages.trade.validation.proof.none",
   1417             Self::Sp1Core => "messages.trade.validation.proof.sp1_core",
   1418             Self::Sp1Compressed => "messages.trade.validation.proof.sp1_compressed",
   1419             Self::Sp1Groth16 => "messages.trade.validation.proof.sp1_groth16",
   1420             Self::Sp1Plonk => "messages.trade.validation.proof.sp1_plonk",
   1421         }
   1422     }
   1423 
   1424     pub const fn from_validation_receipt_proof_system(
   1425         proof_system: RadrootsValidationReceiptProofSystem,
   1426     ) -> Self {
   1427         match proof_system {
   1428             RadrootsValidationReceiptProofSystem::None => Self::None,
   1429             RadrootsValidationReceiptProofSystem::Sp1Core => Self::Sp1Core,
   1430             RadrootsValidationReceiptProofSystem::Sp1Compressed => Self::Sp1Compressed,
   1431             RadrootsValidationReceiptProofSystem::Sp1Groth16 => Self::Sp1Groth16,
   1432             RadrootsValidationReceiptProofSystem::Sp1Plonk => Self::Sp1Plonk,
   1433         }
   1434     }
   1435 }
   1436 
   1437 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1438 pub struct TradeValidationReceiptProjection {
   1439     pub event_id: String,
   1440     pub result: TradeValidationReceiptResult,
   1441     pub receipt_type: TradeValidationReceiptType,
   1442     pub proof_system: TradeValidationReceiptProofSystem,
   1443     pub event_set_root: String,
   1444     pub reducer_output_root: String,
   1445     pub public_values_hash: String,
   1446     pub target_event_id: String,
   1447     pub recorded_at: u64,
   1448 }
   1449 
   1450 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1451 pub struct TradeWorkflowProjection {
   1452     pub order_id: OrderId,
   1453     pub agreement: TradeAgreementStatus,
   1454     pub revision: TradeRevisionStatus,
   1455     pub economics: TradeEconomicsProjection,
   1456     pub inventory: TradeInventoryStatus,
   1457     pub provenance: TradeProvenanceProjection,
   1458 }
   1459 
   1460 impl TradeWorkflowProjection {
   1461     pub fn new(order_id: OrderId, agreement: TradeAgreementStatus) -> Self {
   1462         Self {
   1463             order_id,
   1464             agreement,
   1465             revision: TradeRevisionStatus::None,
   1466             economics: TradeEconomicsProjection::default(),
   1467             inventory: TradeInventoryStatus::NeedsReview,
   1468             provenance: TradeProvenanceProjection::default(),
   1469         }
   1470     }
   1471 
   1472     pub fn from_active_order_projection(
   1473         order_id: OrderId,
   1474         projection: &RadrootsOrderProjection,
   1475         revision: TradeRevisionStatus,
   1476         provenance: TradeProvenanceProjection,
   1477     ) -> Self {
   1478         let mut workflow = Self::new(
   1479             order_id,
   1480             TradeAgreementStatus::from_active_order_status(&projection.status),
   1481         );
   1482         workflow.revision = revision;
   1483         workflow.economics = projection
   1484             .economics
   1485             .as_ref()
   1486             .map(TradeEconomicsProjection::from_trade_order_economics)
   1487             .unwrap_or_default();
   1488         workflow.inventory = TradeInventoryStatus::from_active_order_projection(projection);
   1489         workflow.provenance = provenance
   1490             .with_last_event_id(projection.last_event_id.as_ref().map(ToString::to_string));
   1491         workflow
   1492     }
   1493 
   1494     pub fn from_order_status(order_id: OrderId, status: OrderStatus) -> Self {
   1495         let mut projection = match status {
   1496             OrderStatus::NeedsAction => Self::new(order_id, TradeAgreementStatus::Ordered),
   1497             OrderStatus::Scheduled => Self::new(order_id, TradeAgreementStatus::Confirmed),
   1498             OrderStatus::Packed => Self::new(order_id, TradeAgreementStatus::Confirmed),
   1499             OrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Confirmed),
   1500             OrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined),
   1501             OrderStatus::NeedsReview => Self::new(order_id, TradeAgreementStatus::NeedsReview),
   1502         };
   1503 
   1504         match status {
   1505             OrderStatus::NeedsAction => {}
   1506             OrderStatus::Scheduled => {
   1507                 projection.inventory = TradeInventoryStatus::Reserved;
   1508             }
   1509             OrderStatus::Packed => {
   1510                 projection.inventory = TradeInventoryStatus::Reserved;
   1511             }
   1512             OrderStatus::Completed => {
   1513                 projection.inventory = TradeInventoryStatus::Reserved;
   1514             }
   1515             OrderStatus::Declined => {
   1516                 projection.inventory = TradeInventoryStatus::Available;
   1517             }
   1518             OrderStatus::NeedsReview => {}
   1519         }
   1520 
   1521         projection
   1522     }
   1523 
   1524     pub fn from_buyer_order_status(order_id: OrderId, status: BuyerOrderStatus) -> Self {
   1525         let mut projection = match status {
   1526             BuyerOrderStatus::Placed => Self::new(order_id, TradeAgreementStatus::Ordered),
   1527             BuyerOrderStatus::Scheduled => Self::new(order_id, TradeAgreementStatus::Confirmed),
   1528             BuyerOrderStatus::Ready => Self::new(order_id, TradeAgreementStatus::Confirmed),
   1529             BuyerOrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Confirmed),
   1530             BuyerOrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined),
   1531             BuyerOrderStatus::NeedsReview => Self::new(order_id, TradeAgreementStatus::NeedsReview),
   1532         };
   1533 
   1534         match status {
   1535             BuyerOrderStatus::Placed => {}
   1536             BuyerOrderStatus::Scheduled => {
   1537                 projection.inventory = TradeInventoryStatus::Reserved;
   1538             }
   1539             BuyerOrderStatus::Ready => {
   1540                 projection.inventory = TradeInventoryStatus::Reserved;
   1541             }
   1542             BuyerOrderStatus::Completed => {
   1543                 projection.inventory = TradeInventoryStatus::Reserved;
   1544             }
   1545             BuyerOrderStatus::Declined => {
   1546                 projection.inventory = TradeInventoryStatus::Available;
   1547             }
   1548             BuyerOrderStatus::NeedsReview => {}
   1549         }
   1550 
   1551         projection
   1552     }
   1553 
   1554     pub fn with_economics(mut self, economics: TradeEconomicsProjection) -> Self {
   1555         self.economics = economics;
   1556         self
   1557     }
   1558 
   1559     pub fn with_revision(mut self, revision: TradeRevisionStatus) -> Self {
   1560         self.revision = revision;
   1561         self
   1562     }
   1563 }
   1564 
   1565 pub fn order_status_from_active_order_projection(
   1566     projection: &RadrootsOrderProjection,
   1567 ) -> Option<OrderStatus> {
   1568     match projection.status {
   1569         RadrootsOrderStatus::Missing => None,
   1570         RadrootsOrderStatus::Requested => Some(OrderStatus::NeedsAction),
   1571         RadrootsOrderStatus::Accepted => Some(OrderStatus::Scheduled),
   1572         RadrootsOrderStatus::Declined | RadrootsOrderStatus::Cancelled => {
   1573             Some(OrderStatus::Declined)
   1574         }
   1575         RadrootsOrderStatus::Invalid => Some(OrderStatus::NeedsAction),
   1576     }
   1577 }
   1578 
   1579 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1580 #[serde(rename_all = "snake_case")]
   1581 pub enum OrdersFilter {
   1582     All,
   1583     #[default]
   1584     NeedsAction,
   1585     Scheduled,
   1586     Packed,
   1587     Completed,
   1588 }
   1589 
   1590 impl OrdersFilter {
   1591     pub const fn storage_key(self) -> &'static str {
   1592         match self {
   1593             Self::All => "all",
   1594             Self::NeedsAction => "needs_action",
   1595             Self::Scheduled => "scheduled",
   1596             Self::Packed => "packed",
   1597             Self::Completed => "completed",
   1598         }
   1599     }
   1600 }
   1601 
   1602 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1603 pub struct OrdersScreenQueryState {
   1604     pub filter: OrdersFilter,
   1605     pub fulfillment_window_id: Option<FulfillmentWindowId>,
   1606 }
   1607 
   1608 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1609 #[serde(rename_all = "snake_case")]
   1610 pub enum OrderPrimaryAction {
   1611     Review,
   1612 }
   1613 
   1614 impl OrderPrimaryAction {
   1615     pub const fn storage_key(self) -> &'static str {
   1616         match self {
   1617             Self::Review => "review",
   1618         }
   1619     }
   1620 }
   1621 
   1622 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1623 pub struct OrdersListSummary {
   1624     pub total_orders: u32,
   1625     pub needs_action_orders: u32,
   1626     pub scheduled_orders: u32,
   1627     pub packed_orders: u32,
   1628 }
   1629 
   1630 impl OrdersListSummary {
   1631     pub const fn has_orders(&self) -> bool {
   1632         self.total_orders > 0
   1633     }
   1634 }
   1635 
   1636 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1637 pub struct OrdersListRow {
   1638     pub order_id: OrderId,
   1639     pub farm_id: FarmId,
   1640     pub fulfillment_window_id: Option<FulfillmentWindowId>,
   1641     pub order_number: String,
   1642     pub customer_display_name: String,
   1643     pub fulfillment_window_label: Option<String>,
   1644     pub pickup_location_label: Option<String>,
   1645     pub status: OrderStatus,
   1646     pub workflow: TradeWorkflowProjection,
   1647     pub primary_action: Option<OrderPrimaryAction>,
   1648 }
   1649 
   1650 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1651 pub struct OrdersListProjection {
   1652     pub summary: OrdersListSummary,
   1653     pub rows: Vec<OrdersListRow>,
   1654 }
   1655 
   1656 impl OrdersListProjection {
   1657     pub fn is_empty(&self) -> bool {
   1658         self.rows.is_empty()
   1659     }
   1660 }
   1661 
   1662 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1663 pub struct OrderDetailItemRow {
   1664     pub title: String,
   1665     pub quantity_display: String,
   1666     pub unit_price: Option<ProductPricePresentation>,
   1667     pub line_total_minor_units: Option<u32>,
   1668 }
   1669 
   1670 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1671 pub struct OrderDetailProjection {
   1672     pub order_id: OrderId,
   1673     pub farm_id: FarmId,
   1674     pub order_number: String,
   1675     pub customer_display_name: String,
   1676     pub status: OrderStatus,
   1677     pub fulfillment_window_id: Option<FulfillmentWindowId>,
   1678     pub fulfillment_window_label: Option<String>,
   1679     pub pickup_location_label: Option<String>,
   1680     pub items: Vec<OrderDetailItemRow>,
   1681     pub economics: TradeEconomicsProjection,
   1682     pub workflow: TradeWorkflowProjection,
   1683     pub validation_receipts: Vec<TradeValidationReceiptProjection>,
   1684     pub primary_action: Option<OrderPrimaryAction>,
   1685 }
   1686 
   1687 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1688 pub struct BuyerOrdersListRow {
   1689     pub order_id: OrderId,
   1690     pub farm_id: FarmId,
   1691     pub order_number: String,
   1692     pub farm_display_name: String,
   1693     pub fulfillment_summary: String,
   1694     pub status: BuyerOrderStatus,
   1695     pub workflow: TradeWorkflowProjection,
   1696     pub repeat_demand: Option<RepeatDemandHandoffProjection>,
   1697 }
   1698 
   1699 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1700 pub struct BuyerOrdersProjection {
   1701     pub rows: Vec<BuyerOrdersListRow>,
   1702 }
   1703 
   1704 impl BuyerOrdersProjection {
   1705     pub fn is_empty(&self) -> bool {
   1706         self.rows.is_empty()
   1707     }
   1708 }
   1709 
   1710 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1711 pub struct BuyerOrderDetailProjection {
   1712     pub order_id: OrderId,
   1713     pub farm_id: FarmId,
   1714     pub order_number: String,
   1715     pub farm_display_name: String,
   1716     pub fulfillment_summary: String,
   1717     pub status: BuyerOrderStatus,
   1718     pub items: Vec<OrderDetailItemRow>,
   1719     pub economics: TradeEconomicsProjection,
   1720     pub workflow: TradeWorkflowProjection,
   1721     pub validation_receipts: Vec<TradeValidationReceiptProjection>,
   1722     pub order_note: Option<String>,
   1723     pub repeat_demand: Option<RepeatDemandHandoffProjection>,
   1724 }
   1725 
   1726 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1727 pub struct PackDayScreenQueryState {
   1728     pub fulfillment_window_id: Option<FulfillmentWindowId>,
   1729 }
   1730 
   1731 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1732 pub struct PackDayProductTotalRow {
   1733     pub title: String,
   1734     pub quantity_display: String,
   1735 }
   1736 
   1737 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1738 pub struct PackDayPackListRow {
   1739     pub title: String,
   1740     pub quantity_display: String,
   1741 }
   1742 
   1743 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1744 pub struct PackDayRosterRow {
   1745     pub order_id: OrderId,
   1746     pub order_number: String,
   1747     pub customer_display_name: String,
   1748 }
   1749 
   1750 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1751 pub struct PackDayProjection {
   1752     pub fulfillment_window: Option<FulfillmentWindowSummary>,
   1753     pub reminders: ReminderFeedProjection,
   1754     pub totals_by_product: Vec<PackDayProductTotalRow>,
   1755     pub pack_list: Vec<PackDayPackListRow>,
   1756     pub pickup_roster: Vec<PackDayRosterRow>,
   1757 }
   1758 
   1759 impl PackDayProjection {
   1760     pub fn is_empty(&self) -> bool {
   1761         self.totals_by_product.is_empty()
   1762             && self.pack_list.is_empty()
   1763             && self.pickup_roster.is_empty()
   1764     }
   1765 }
   1766 
   1767 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1768 pub struct FarmSummary {
   1769     pub farm_id: FarmId,
   1770     pub display_name: String,
   1771     pub readiness: FarmReadiness,
   1772 }
   1773 
   1774 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1775 #[serde(rename_all = "snake_case")]
   1776 pub enum FarmSetupReadiness {
   1777     #[default]
   1778     NotStarted,
   1779     InProgress,
   1780     Ready,
   1781 }
   1782 
   1783 impl FarmSetupReadiness {
   1784     pub const fn storage_key(self) -> &'static str {
   1785         match self {
   1786             Self::NotStarted => "not_started",
   1787             Self::InProgress => "in_progress",
   1788             Self::Ready => "ready",
   1789         }
   1790     }
   1791 }
   1792 
   1793 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1794 #[serde(rename_all = "snake_case")]
   1795 pub enum FarmSetupSection {
   1796     Farm,
   1797     Location,
   1798     OrderMethods,
   1799 }
   1800 
   1801 impl FarmSetupSection {
   1802     pub const fn ordered() -> [Self; 3] {
   1803         [Self::Farm, Self::Location, Self::OrderMethods]
   1804     }
   1805 
   1806     pub const fn storage_key(self) -> &'static str {
   1807         match self {
   1808             Self::Farm => "farm",
   1809             Self::Location => "location",
   1810             Self::OrderMethods => "order_methods",
   1811         }
   1812     }
   1813 }
   1814 
   1815 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1816 #[serde(rename_all = "snake_case")]
   1817 pub enum FarmSetupBlocker {
   1818     AddFarmName,
   1819     AddLocationOrServiceArea,
   1820     ChooseOrderMethod,
   1821 }
   1822 
   1823 impl FarmSetupBlocker {
   1824     pub const fn storage_key(self) -> &'static str {
   1825         match self {
   1826             Self::AddFarmName => "add_farm_name",
   1827             Self::AddLocationOrServiceArea => "add_location_or_service_area",
   1828             Self::ChooseOrderMethod => "choose_order_method",
   1829         }
   1830     }
   1831 }
   1832 
   1833 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1834 pub struct FarmSetupDraft {
   1835     pub farm_name: String,
   1836     pub location_or_service_area: String,
   1837     pub order_methods: BTreeSet<FarmOrderMethod>,
   1838 }
   1839 
   1840 impl FarmSetupDraft {
   1841     pub fn new(
   1842         farm_name: impl Into<String>,
   1843         location_or_service_area: impl Into<String>,
   1844         order_methods: impl IntoIterator<Item = FarmOrderMethod>,
   1845     ) -> Self {
   1846         Self {
   1847             farm_name: farm_name.into(),
   1848             location_or_service_area: location_or_service_area.into(),
   1849             order_methods: order_methods.into_iter().collect(),
   1850         }
   1851     }
   1852 
   1853     pub fn blockers(&self) -> Vec<FarmSetupBlocker> {
   1854         let mut blockers = Vec::new();
   1855 
   1856         if self.farm_name.trim().is_empty() {
   1857             blockers.push(FarmSetupBlocker::AddFarmName);
   1858         }
   1859 
   1860         if self.location_or_service_area.trim().is_empty() {
   1861             blockers.push(FarmSetupBlocker::AddLocationOrServiceArea);
   1862         }
   1863 
   1864         if self.order_methods.is_empty() {
   1865             blockers.push(FarmSetupBlocker::ChooseOrderMethod);
   1866         }
   1867 
   1868         blockers
   1869     }
   1870 
   1871     pub fn readiness(&self) -> FarmSetupReadiness {
   1872         let blockers = self.blockers();
   1873         if blockers.is_empty() {
   1874             FarmSetupReadiness::Ready
   1875         } else if self.is_empty() {
   1876             FarmSetupReadiness::NotStarted
   1877         } else {
   1878             FarmSetupReadiness::InProgress
   1879         }
   1880     }
   1881 
   1882     pub fn is_empty(&self) -> bool {
   1883         self.farm_name.trim().is_empty()
   1884             && self.location_or_service_area.trim().is_empty()
   1885             && self.order_methods.is_empty()
   1886     }
   1887 }
   1888 
   1889 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1890 pub struct FarmSetupProjection {
   1891     pub draft: FarmSetupDraft,
   1892     pub saved_farm: Option<FarmSummary>,
   1893     pub readiness: FarmSetupReadiness,
   1894     pub blockers: Vec<FarmSetupBlocker>,
   1895 }
   1896 
   1897 impl Default for FarmSetupProjection {
   1898     fn default() -> Self {
   1899         Self::not_started()
   1900     }
   1901 }
   1902 
   1903 impl FarmSetupProjection {
   1904     pub fn new(draft: FarmSetupDraft, saved_farm: Option<FarmSummary>) -> Self {
   1905         match saved_farm {
   1906             Some(saved_farm) => Self {
   1907                 draft,
   1908                 saved_farm: Some(saved_farm),
   1909                 readiness: FarmSetupReadiness::Ready,
   1910                 blockers: Vec::new(),
   1911             },
   1912             None => Self::from_draft(draft),
   1913         }
   1914     }
   1915 
   1916     pub fn not_started() -> Self {
   1917         Self::from_draft(FarmSetupDraft::default())
   1918     }
   1919 
   1920     pub fn from_draft(draft: FarmSetupDraft) -> Self {
   1921         let readiness = draft.readiness();
   1922         let blockers = draft.blockers();
   1923 
   1924         Self {
   1925             draft,
   1926             saved_farm: None,
   1927             readiness,
   1928             blockers,
   1929         }
   1930     }
   1931 
   1932     pub fn from_saved_farm(saved_farm: FarmSummary) -> Self {
   1933         Self {
   1934             draft: FarmSetupDraft::default(),
   1935             saved_farm: Some(saved_farm),
   1936             readiness: FarmSetupReadiness::Ready,
   1937             blockers: Vec::new(),
   1938         }
   1939     }
   1940 
   1941     pub const fn has_saved_farm(&self) -> bool {
   1942         self.saved_farm.is_some()
   1943     }
   1944 }
   1945 
   1946 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1947 pub struct FulfillmentWindowSummary {
   1948     pub fulfillment_window_id: FulfillmentWindowId,
   1949     pub farm_id: FarmId,
   1950     pub starts_at: String,
   1951     pub ends_at: String,
   1952 }
   1953 
   1954 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1955 pub struct TodaySummary {
   1956     pub farm_id: FarmId,
   1957     pub orders_needing_action: u32,
   1958     pub low_stock_products: u32,
   1959     pub draft_products: u32,
   1960     pub reminders_due_soon: u32,
   1961 }
   1962 
   1963 impl TodaySummary {
   1964     pub const fn has_attention_items(&self) -> bool {
   1965         self.orders_needing_action > 0
   1966             || self.low_stock_products > 0
   1967             || self.draft_products > 0
   1968             || self.reminders_due_soon > 0
   1969     }
   1970 }
   1971 
   1972 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1973 pub struct ReminderDeadlineProjection {
   1974     pub reminder_id: ReminderId,
   1975     pub farm_id: FarmId,
   1976     pub order_id: Option<OrderId>,
   1977     pub fulfillment_window_id: Option<FulfillmentWindowId>,
   1978     pub kind: ReminderKind,
   1979     pub surface: ReminderSurface,
   1980     pub urgency: ReminderUrgency,
   1981     pub title: String,
   1982     pub detail: String,
   1983     pub deadline_at: String,
   1984     pub action_label: Option<String>,
   1985     pub delivery_state: ReminderDeliveryState,
   1986 }
   1987 
   1988 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   1989 pub struct ReminderFeedProjection {
   1990     pub items: Vec<ReminderDeadlineProjection>,
   1991 }
   1992 
   1993 impl ReminderFeedProjection {
   1994     pub fn is_empty(&self) -> bool {
   1995         self.items.is_empty()
   1996     }
   1997 
   1998     pub fn due_soon_count(&self) -> usize {
   1999         self.items
   2000             .iter()
   2001             .filter(|item| {
   2002                 matches!(
   2003                     item.urgency,
   2004                     ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking
   2005                 )
   2006             })
   2007             .count()
   2008     }
   2009 }
   2010 
   2011 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   2012 pub struct ReminderLogEntryProjection {
   2013     pub reminder_id: ReminderId,
   2014     pub kind: ReminderKind,
   2015     pub title: String,
   2016     pub recorded_at: String,
   2017     pub delivery_state: ReminderDeliveryState,
   2018     pub detail: Option<String>,
   2019 }
   2020 
   2021 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   2022 pub struct ReminderLogProjection {
   2023     pub entries: Vec<ReminderLogEntryProjection>,
   2024 }
   2025 
   2026 impl ReminderLogProjection {
   2027     pub fn is_empty(&self) -> bool {
   2028         self.entries.is_empty()
   2029     }
   2030 }
   2031 
   2032 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   2033 pub struct RepeatDemandHandoffProjection {
   2034     pub order_id: OrderId,
   2035     pub farm_id: FarmId,
   2036     pub eligibility: RepeatDemandEligibility,
   2037     pub available_item_count: u32,
   2038     pub unavailable_item_count: u32,
   2039 }
   2040 
   2041 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   2042 pub struct ProductListRow {
   2043     pub product_id: ProductId,
   2044     pub farm_id: FarmId,
   2045     pub title: String,
   2046     pub status: ProductStatus,
   2047     pub stock_count: u32,
   2048 }
   2049 
   2050 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   2051 pub struct OrderListRow {
   2052     pub order_id: OrderId,
   2053     pub farm_id: FarmId,
   2054     pub fulfillment_window_id: Option<FulfillmentWindowId>,
   2055     pub order_number: String,
   2056     pub customer_display_name: String,
   2057     pub status: OrderStatus,
   2058 }
   2059 
   2060 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
   2061 #[serde(rename_all = "snake_case")]
   2062 pub enum TodaySetupTaskKind {
   2063     CompleteFarmProfile,
   2064     AddPickupLocation,
   2065     AddOperatingRules,
   2066     AddFulfillmentWindow,
   2067     ResolveAvailabilityConflicts,
   2068     PublishProduct,
   2069 }
   2070 
   2071 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   2072 pub struct TodaySetupTask {
   2073     pub kind: TodaySetupTaskKind,
   2074     pub is_complete: bool,
   2075 }
   2076 
   2077 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
   2078 pub struct TodayAgendaProjection {
   2079     pub farm: Option<FarmSummary>,
   2080     pub summary: Option<TodaySummary>,
   2081     pub reminders: ReminderFeedProjection,
   2082     pub orders_needing_action: Vec<OrderListRow>,
   2083     pub low_stock_products: Vec<ProductListRow>,
   2084     pub draft_products: Vec<ProductListRow>,
   2085     pub next_fulfillment_window: Option<FulfillmentWindowSummary>,
   2086     pub setup_checklist: Vec<TodaySetupTask>,
   2087 }
   2088 
   2089 impl TodayAgendaProjection {
   2090     pub fn has_attention_items(&self) -> bool {
   2091         self.summary
   2092             .as_ref()
   2093             .is_some_and(TodaySummary::has_attention_items)
   2094             || !self.reminders.is_empty()
   2095             || !self.orders_needing_action.is_empty()
   2096             || !self.low_stock_products.is_empty()
   2097             || !self.draft_products.is_empty()
   2098     }
   2099 
   2100     pub fn needs_setup(&self) -> bool {
   2101         self.setup_checklist.iter().any(|item| !item.is_complete)
   2102     }
   2103 }
   2104 
   2105 #[cfg(test)]
   2106 mod tests {
   2107     use radroots_core::{
   2108         RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
   2109     };
   2110     use radroots_events::ids::{
   2111         RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId,
   2112         RadrootsOrderQuoteId, RadrootsPublicKey,
   2113     };
   2114     use radroots_events::order::{
   2115         RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderPricingBasis,
   2116     };
   2117     use radroots_trade::order::{RadrootsOrderProjection, RadrootsOrderStatus};
   2118     use radroots_trade::validation_receipt::{
   2119         RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult,
   2120         RadrootsValidationReceiptType,
   2121     };
   2122 
   2123     use super::{
   2124         AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface,
   2125         ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind,
   2126         AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection,
   2127         BuyerCartProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection,
   2128         BuyerOrderDetailProjection, BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft,
   2129         BuyerOrderReviewProjection, BuyerOrderReviewSummaryProjection, BuyerOrderStatus,
   2130         BuyerOrdersListRow, BuyerOrdersProjection, FarmId, FarmOrderMethod, FarmReadinessBlocker,
   2131         FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft,
   2132         FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, FarmTimingConflict,
   2133         FarmTimingConflictKind, FarmerActivationProjection, FarmerSection, FulfillmentWindowId,
   2134         IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase,
   2135         LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId,
   2136         OrderListRow, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection,
   2137         OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayBatchPrintArtifact,
   2138         PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, PackDayExportArtifact,
   2139         PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId,
   2140         PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus,
   2141         PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry,
   2142         PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow,
   2143         PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock,
   2144         PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow,
   2145         PackDayScreenQueryState, ParseStartupSignerSourceError, PersonalEntryProjection,
   2146         PersonalEntryState, PersonalSection, PickupLocationId, ProductAttentionState,
   2147         ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow,
   2148         ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState,
   2149         ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow,
   2150         ProductsListSummary, ProductsSort, ReminderDeadlineProjection, ReminderDeliveryState,
   2151         ReminderFeedProjection, ReminderId, ReminderKind, ReminderLogEntryProjection,
   2152         ReminderLogProjection, ReminderSurface, ReminderUrgency, RepeatDemandEligibility,
   2153         RepeatDemandHandoffProjection, SelectedAccountProjection, SelectedSurfaceProjection,
   2154         SettingsPreference, SettingsSection, ShellSection, StartupSignerEntryProjection,
   2155         StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask,
   2156         TodaySetupTaskKind, TodaySummary, TradeAgreementStatus, TradeEconomicsProjection,
   2157         TradeInventoryStatus, TradeProvenanceProjection, TradeRevisionStatus,
   2158         TradeValidationReceiptProofSystem, TradeValidationReceiptResult,
   2159         TradeValidationReceiptType, TradeWorkflowProjection, TradeWorkflowSource,
   2160         order_status_from_active_order_projection,
   2161     };
   2162     use std::{collections::BTreeSet, str::FromStr};
   2163     use uuid::Uuid;
   2164 
   2165     #[test]
   2166     fn shell_section_storage_keys_are_unique_and_round_trip() {
   2167         let sections = [
   2168             ShellSection::Home,
   2169             ShellSection::Personal(PersonalSection::Browse),
   2170             ShellSection::Personal(PersonalSection::Search),
   2171             ShellSection::Personal(PersonalSection::Cart),
   2172             ShellSection::Personal(PersonalSection::Orders),
   2173             ShellSection::Account,
   2174             ShellSection::Farmer(FarmerSection::Today),
   2175             ShellSection::Farmer(FarmerSection::Products),
   2176             ShellSection::Farmer(FarmerSection::Orders),
   2177             ShellSection::Farmer(FarmerSection::PackDay),
   2178             ShellSection::Farmer(FarmerSection::Farm),
   2179             ShellSection::Settings(SettingsSection::Account),
   2180             ShellSection::Settings(SettingsSection::Farm),
   2181             ShellSection::Settings(SettingsSection::Settings),
   2182             ShellSection::Settings(SettingsSection::About),
   2183         ];
   2184         let keys = sections
   2185             .into_iter()
   2186             .map(ShellSection::storage_key)
   2187             .collect::<BTreeSet<_>>();
   2188 
   2189         assert_eq!(keys.len(), sections.len());
   2190 
   2191         for section in sections {
   2192             let parsed =
   2193                 ShellSection::from_str(section.storage_key()).expect("section should parse");
   2194             assert_eq!(parsed, section);
   2195         }
   2196     }
   2197 
   2198     #[test]
   2199     fn shell_section_surface_is_explicit_for_surface_routes_only() {
   2200         assert_eq!(ShellSection::Home.surface(), None);
   2201         assert_eq!(ShellSection::Account.surface(), None);
   2202         assert_eq!(
   2203             ShellSection::Personal(PersonalSection::Browse).surface(),
   2204             Some(ActiveSurface::Personal)
   2205         );
   2206         assert_eq!(
   2207             ShellSection::Farmer(FarmerSection::Today).surface(),
   2208             Some(ActiveSurface::Farmer)
   2209         );
   2210         assert_eq!(
   2211             ShellSection::Settings(SettingsSection::Settings).surface(),
   2212             None
   2213         );
   2214     }
   2215 
   2216     #[test]
   2217     fn shell_section_default_for_surface_preserves_current_farmer_entry() {
   2218         assert_eq!(
   2219             ShellSection::default_for_surface(ActiveSurface::Personal),
   2220             ShellSection::Personal(PersonalSection::Browse)
   2221         );
   2222         assert_eq!(
   2223             ShellSection::default_for_surface(ActiveSurface::Farmer),
   2224             ShellSection::Farmer(FarmerSection::Today)
   2225         );
   2226     }
   2227 
   2228     #[test]
   2229     fn selected_surface_defaults_to_personal() {
   2230         assert_eq!(
   2231             SelectedSurfaceProjection::default().active_surface,
   2232             ActiveSurface::Personal
   2233         );
   2234     }
   2235 
   2236     #[test]
   2237     fn selected_account_without_farmer_activation_falls_back_to_personal_surface() {
   2238         let projection = SelectedAccountProjection::new(
   2239             AccountSummary {
   2240                 account_id: "acct_01".to_owned(),
   2241                 npub: "npub1example".to_owned(),
   2242                 label: Some("North field".to_owned()),
   2243                 custody: AccountCustody::LocalManaged,
   2244             },
   2245             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
   2246             FarmerActivationProjection::inactive(),
   2247         );
   2248 
   2249         assert_eq!(projection.active_surface(), ActiveSurface::Personal);
   2250         assert!(!projection.farmer_activation.is_active());
   2251     }
   2252 
   2253     #[test]
   2254     fn account_surface_activation_projection_normalizes_to_personal_without_farm_binding() {
   2255         let projection = AccountSurfaceActivationProjection::new(
   2256             "acct_04",
   2257             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
   2258             FarmerActivationProjection::inactive(),
   2259         );
   2260 
   2261         assert_eq!(projection.account_id, "acct_04");
   2262         assert_eq!(projection.active_surface(), ActiveSurface::Personal);
   2263         assert!(!projection.farmer_activation.is_active());
   2264     }
   2265 
   2266     #[test]
   2267     fn selected_account_projection_round_trips_through_surface_activation_state() {
   2268         let selected_account = SelectedAccountProjection::new(
   2269             AccountSummary {
   2270                 account_id: "acct_roundtrip".to_owned(),
   2271                 npub: "npub1roundtrip".to_owned(),
   2272                 label: Some("Roundtrip".to_owned()),
   2273                 custody: AccountCustody::LocalManaged,
   2274             },
   2275             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
   2276             FarmerActivationProjection::active(FarmId::new()),
   2277         );
   2278         let activation = AccountSurfaceActivationProjection::from(&selected_account);
   2279         let restored = SelectedAccountProjection::from_surface_activation(
   2280             selected_account.account.clone(),
   2281             activation,
   2282         );
   2283 
   2284         assert_eq!(restored, selected_account);
   2285     }
   2286 
   2287     #[test]
   2288     fn startup_gate_tracks_setup_personal_farmer_and_blocked_states() {
   2289         let farmer_identity = AppIdentityProjection::ready(
   2290             Vec::new(),
   2291             SelectedAccountProjection::new(
   2292                 AccountSummary {
   2293                     account_id: "acct_02".to_owned(),
   2294                     npub: "npub1farmer".to_owned(),
   2295                     label: None,
   2296                     custody: AccountCustody::LocalManaged,
   2297                 },
   2298                 SelectedSurfaceProjection::new(ActiveSurface::Farmer),
   2299                 FarmerActivationProjection::active(FarmId::new()),
   2300             ),
   2301         );
   2302         let personal_identity = AppIdentityProjection::ready(
   2303             Vec::new(),
   2304             SelectedAccountProjection::new(
   2305                 AccountSummary {
   2306                     account_id: "acct_03".to_owned(),
   2307                     npub: "npub1personal".to_owned(),
   2308                     label: None,
   2309                     custody: AccountCustody::LocalManaged,
   2310                 },
   2311                 SelectedSurfaceProjection::new(ActiveSurface::Personal),
   2312                 FarmerActivationProjection::inactive(),
   2313             ),
   2314         );
   2315 
   2316         assert_eq!(
   2317             AppIdentityProjection::missing().startup_gate(),
   2318             AppStartupGate::SetupRequired
   2319         );
   2320         assert_eq!(personal_identity.startup_gate(), AppStartupGate::Personal);
   2321         assert_eq!(farmer_identity.startup_gate(), AppStartupGate::Farmer);
   2322         assert_eq!(
   2323             AppIdentityProjection::blocked(IdentityBlockedReason::HostVaultUnavailable)
   2324                 .startup_gate(),
   2325             AppStartupGate::Blocked
   2326         );
   2327     }
   2328 
   2329     #[test]
   2330     fn ready_identity_keeps_selected_account_visible_in_roster() {
   2331         let selected_account = SelectedAccountProjection::new(
   2332             AccountSummary {
   2333                 account_id: "acct_selected".to_owned(),
   2334                 npub: "npub1selected".to_owned(),
   2335                 label: None,
   2336                 custody: AccountCustody::RemoteSigner,
   2337             },
   2338             SelectedSurfaceProjection::new(ActiveSurface::Personal),
   2339             FarmerActivationProjection::inactive(),
   2340         );
   2341         let projection = AppIdentityProjection::ready(Vec::new(), selected_account.clone());
   2342 
   2343         assert_eq!(projection.readiness.storage_key(), "ready");
   2344         assert_eq!(projection.roster.len(), 1);
   2345         assert_eq!(projection.roster[0], selected_account.account);
   2346         assert_eq!(projection.selected_account, Some(selected_account));
   2347     }
   2348 
   2349     #[test]
   2350     fn blocked_identity_keeps_selected_account_visible_in_roster() {
   2351         let selected_account = SelectedAccountProjection::new(
   2352             AccountSummary {
   2353                 account_id: "acct_blocked".to_owned(),
   2354                 npub: "npub1blocked".to_owned(),
   2355                 label: Some("Blocked account".to_owned()),
   2356                 custody: AccountCustody::LocalManaged,
   2357             },
   2358             SelectedSurfaceProjection::new(ActiveSurface::Personal),
   2359             FarmerActivationProjection::inactive(),
   2360         );
   2361         let projection = AppIdentityProjection::blocked_with_selection(
   2362             IdentityBlockedReason::HostVaultUnavailable,
   2363             Vec::new(),
   2364             Some(selected_account.clone()),
   2365         );
   2366 
   2367         assert_eq!(
   2368             projection.readiness,
   2369             IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable)
   2370         );
   2371         assert_eq!(projection.roster, vec![selected_account.account.clone()]);
   2372         assert_eq!(projection.selected_account, Some(selected_account));
   2373         assert_eq!(projection.startup_gate(), AppStartupGate::Blocked);
   2374     }
   2375 
   2376     #[test]
   2377     fn missing_identity_can_keep_roster_visible_without_selected_account() {
   2378         let roster = vec![AccountSummary {
   2379             account_id: "acct_waiting".to_owned(),
   2380             npub: "npub1waiting".to_owned(),
   2381             label: Some("Waiting".to_owned()),
   2382             custody: AccountCustody::LocalManaged,
   2383         }];
   2384         let projection = AppIdentityProjection::missing_with_roster(roster.clone());
   2385 
   2386         assert_eq!(projection.readiness, IdentityReadiness::MissingAccount);
   2387         assert_eq!(projection.roster, roster);
   2388         assert!(projection.selected_account.is_none());
   2389         assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired);
   2390     }
   2391 
   2392     #[test]
   2393     fn personal_entry_projection_is_derived_from_identity_truth() {
   2394         let guest_identity = AppIdentityProjection::missing();
   2395         let selected_account = SelectedAccountProjection::new(
   2396             AccountSummary {
   2397                 account_id: "acct_farmer".to_owned(),
   2398                 npub: "npub1farmer".to_owned(),
   2399                 label: Some("Field stand".to_owned()),
   2400                 custody: AccountCustody::LocalManaged,
   2401             },
   2402             SelectedSurfaceProjection::new(ActiveSurface::Farmer),
   2403             FarmerActivationProjection::active(FarmId::new()),
   2404         );
   2405         let signed_in_identity = AppIdentityProjection::ready(Vec::new(), selected_account.clone());
   2406         let blocked_identity = AppIdentityProjection::blocked_with_selection(
   2407             IdentityBlockedReason::HostVaultUnavailable,
   2408             Vec::new(),
   2409             Some(selected_account.clone()),
   2410         );
   2411 
   2412         assert_eq!(
   2413             guest_identity.personal_entry(),
   2414             PersonalEntryProjection::guest()
   2415         );
   2416         assert_eq!(
   2417             guest_identity.personal_entry().state.storage_key(),
   2418             PersonalEntryState::Guest.storage_key()
   2419         );
   2420         assert_eq!(
   2421             signed_in_identity.personal_entry(),
   2422             PersonalEntryProjection::signed_in(selected_account.clone())
   2423         );
   2424         assert!(
   2425             signed_in_identity
   2426                 .personal_entry()
   2427                 .can_enter_farmer_workspace
   2428         );
   2429         assert_eq!(
   2430             blocked_identity.personal_entry(),
   2431             PersonalEntryProjection::blocked(Some(selected_account))
   2432         );
   2433     }
   2434 
   2435     #[test]
   2436     fn buyer_context_defaults_to_guest_and_tracks_selected_account() {
   2437         let selected_account = SelectedAccountProjection::new(
   2438             AccountSummary {
   2439                 account_id: "acct_buyer".to_owned(),
   2440                 npub: "npub1buyer".to_owned(),
   2441                 label: Some("Buyer".to_owned()),
   2442                 custody: AccountCustody::LocalManaged,
   2443             },
   2444             SelectedSurfaceProjection::new(ActiveSurface::Personal),
   2445             FarmerActivationProjection::inactive(),
   2446         );
   2447         let ready_identity = AppIdentityProjection::ready(Vec::new(), selected_account);
   2448 
   2449         assert_eq!(BuyerContext::guest().storage_key(), "guest");
   2450         assert_eq!(
   2451             BuyerContext::account("acct_buyer").storage_key(),
   2452             "account:acct_buyer"
   2453         );
   2454         assert_eq!(
   2455             AppIdentityProjection::missing().buyer_context(),
   2456             BuyerContext::Guest
   2457         );
   2458         assert_eq!(
   2459             ready_identity.buyer_context(),
   2460             BuyerContext::account("acct_buyer")
   2461         );
   2462     }
   2463 
   2464     #[test]
   2465     fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() {
   2466         assert_eq!(
   2467             LoggedOutStartupProjection::default(),
   2468             LoggedOutStartupProjection {
   2469                 phase: LoggedOutStartupPhase::ContinuePrompt,
   2470                 signer_entry: StartupSignerEntryProjection::default(),
   2471             }
   2472         );
   2473     }
   2474 
   2475     #[test]
   2476     fn logged_out_startup_phase_and_signer_source_kind_storage_keys_are_stable() {
   2477         assert_eq!(
   2478             LoggedOutStartupPhase::ContinuePrompt.storage_key(),
   2479             "continue_prompt"
   2480         );
   2481         assert_eq!(
   2482             LoggedOutStartupPhase::IdentityChoice.storage_key(),
   2483             "identity_choice"
   2484         );
   2485         assert_eq!(
   2486             LoggedOutStartupPhase::GenerateKeyStarting.storage_key(),
   2487             "generate_key_starting"
   2488         );
   2489         assert_eq!(
   2490             LoggedOutStartupPhase::SignerEntry.storage_key(),
   2491             "signer_entry"
   2492         );
   2493         assert_eq!(
   2494             StartupSignerSourceKind::BunkerUri.storage_key(),
   2495             "bunker_uri"
   2496         );
   2497         assert_eq!(
   2498             StartupSignerSourceKind::DiscoveryUrl.storage_key(),
   2499             "discovery_url"
   2500         );
   2501     }
   2502 
   2503     #[test]
   2504     fn startup_signer_source_parses_direct_bunker_uri_and_discovery_url() {
   2505         let bunker_uri =
   2506             "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example&secret=test-secret";
   2507         let discovery_url =
   2508             format!("https://signer.radroots.example/connect?uri={bunker_uri}&label=field");
   2509 
   2510         let bunker_source = bunker_uri
   2511             .parse::<StartupSignerSource>()
   2512             .expect("bunker uri should parse");
   2513         let discovery_source = discovery_url
   2514             .parse::<StartupSignerSource>()
   2515             .expect("discovery url should parse");
   2516 
   2517         assert_eq!(
   2518             bunker_source,
   2519             StartupSignerSource::BunkerUri(bunker_uri.to_owned())
   2520         );
   2521         assert_eq!(bunker_source.kind(), StartupSignerSourceKind::BunkerUri);
   2522         assert_eq!(bunker_source.value(), bunker_uri);
   2523         assert_eq!(
   2524             discovery_source,
   2525             StartupSignerSource::DiscoveryUrl(discovery_url.clone())
   2526         );
   2527         assert_eq!(
   2528             discovery_source.kind(),
   2529             StartupSignerSourceKind::DiscoveryUrl
   2530         );
   2531         assert_eq!(discovery_source.value(), discovery_url);
   2532     }
   2533 
   2534     #[test]
   2535     fn startup_signer_source_rejects_empty_client_uri_and_missing_discovery_uri() {
   2536         assert_eq!(
   2537             "".parse::<StartupSignerSource>(),
   2538             Err(ParseStartupSignerSourceError::EmptyInput)
   2539         );
   2540         assert_eq!(
   2541             "nostrconnect://npub1client?relay=wss%3A%2F%2Frelay.radroots.example&secret=test"
   2542                 .parse::<StartupSignerSource>(),
   2543             Err(ParseStartupSignerSourceError::UnsupportedClientUri)
   2544         );
   2545         assert_eq!(
   2546             "https://signer.radroots.example/connect".parse::<StartupSignerSource>(),
   2547             Err(ParseStartupSignerSourceError::MissingDiscoveryUri)
   2548         );
   2549         assert_eq!(
   2550             "not a signer source".parse::<StartupSignerSource>(),
   2551             Err(ParseStartupSignerSourceError::UnsupportedSource)
   2552         );
   2553     }
   2554 
   2555     #[test]
   2556     fn signer_entry_projection_exposes_the_typed_source_contract() {
   2557         let mut projection = StartupSignerEntryProjection::new(
   2558             " bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example ",
   2559         );
   2560 
   2561         assert_eq!(
   2562             projection.parsed_source(),
   2563             Ok(StartupSignerSource::BunkerUri(
   2564                 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example".to_owned()
   2565             ))
   2566         );
   2567 
   2568         projection.set_source_input("https://signer.radroots.example/connect?uri=bunker://npub1");
   2569         assert_eq!(
   2570             projection.parsed_source(),
   2571             Ok(StartupSignerSource::DiscoveryUrl(
   2572                 "https://signer.radroots.example/connect?uri=bunker://npub1".to_owned()
   2573             ))
   2574         );
   2575     }
   2576 
   2577     #[test]
   2578     fn typed_ids_round_trip_through_strings() {
   2579         let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11")
   2580             .expect("test uuid should parse");
   2581         let farm_id = FarmId::from(uuid);
   2582         let parsed = FarmId::from_str(&farm_id.to_string()).expect("farm id should parse");
   2583 
   2584         assert_eq!(parsed, farm_id);
   2585         assert_eq!(parsed.as_uuid(), uuid);
   2586     }
   2587 
   2588     #[test]
   2589     fn product_status_filter_and_sort_storage_keys_are_stable() {
   2590         assert_eq!(ProductStatus::Draft.storage_key(), "draft");
   2591         assert_eq!(ProductStatus::Published.storage_key(), "published");
   2592         assert_eq!(ProductStatus::Paused.storage_key(), "paused");
   2593         assert_eq!(ProductStatus::Archived.storage_key(), "archived");
   2594         assert!(ProductStatus::Published.is_live());
   2595         assert!(!ProductStatus::Draft.is_live());
   2596 
   2597         assert_eq!(ProductsFilter::default(), ProductsFilter::All);
   2598         assert_eq!(ProductsFilter::All.storage_key(), "all");
   2599         assert_eq!(ProductsFilter::Live.storage_key(), "live");
   2600         assert_eq!(ProductsFilter::Drafts.storage_key(), "drafts");
   2601         assert_eq!(
   2602             ProductsFilter::NeedAttention.storage_key(),
   2603             "need_attention"
   2604         );
   2605         assert_eq!(ProductsFilter::Paused.storage_key(), "paused");
   2606         assert_eq!(ProductsFilter::Archived.storage_key(), "archived");
   2607 
   2608         assert_eq!(ProductsSort::default(), ProductsSort::Updated);
   2609         assert_eq!(ProductsSort::Updated.storage_key(), "updated");
   2610         assert_eq!(ProductsSort::Name.storage_key(), "name");
   2611         assert_eq!(ProductsSort::Availability.storage_key(), "availability");
   2612         assert_eq!(ProductsSort::Stock.storage_key(), "stock");
   2613         assert_eq!(ProductsSort::Price.storage_key(), "price");
   2614     }
   2615 
   2616     #[test]
   2617     fn buyer_order_review_disabled_reason_storage_keys_are_stable() {
   2618         assert_eq!(
   2619             BuyerOrderReviewDisabledReason::EmptyCart.storage_key(),
   2620             "empty_cart"
   2621         );
   2622         assert_eq!(
   2623             BuyerOrderReviewDisabledReason::MissingFulfillment.storage_key(),
   2624             "missing_fulfillment"
   2625         );
   2626         assert_eq!(
   2627             BuyerOrderReviewDisabledReason::MissingName.storage_key(),
   2628             "missing_name"
   2629         );
   2630         assert_eq!(
   2631             BuyerOrderReviewDisabledReason::MissingEmail.storage_key(),
   2632             "missing_email"
   2633         );
   2634         assert_eq!(
   2635             BuyerOrderReviewDisabledReason::AccountRequired.storage_key(),
   2636             "account_required"
   2637         );
   2638     }
   2639 
   2640     #[test]
   2641     fn product_attention_stock_and_projection_states_are_explicit() {
   2642         let row = ProductsListRow {
   2643             product_id: super::ProductId::new(),
   2644             farm_id: FarmId::new(),
   2645             title: "Pea shoots".to_owned(),
   2646             subtitle: Some("Tray-grown".to_owned()),
   2647             status: ProductStatus::Draft,
   2648             attention_state: ProductAttentionState::MissingAvailability,
   2649             availability: ProductAvailabilitySummary {
   2650                 state: ProductAvailabilityState::MissingWindow,
   2651                 label: "Missing window".to_owned(),
   2652             },
   2653             stock: ProductStockSummary {
   2654                 quantity: None,
   2655                 unit_label: None,
   2656                 state: ProductStockState::Unset,
   2657             },
   2658             price: Some(ProductPricePresentation {
   2659                 amount_minor_units: 300,
   2660                 currency_code: "USD".to_owned(),
   2661                 unit_label: "bag".to_owned(),
   2662             }),
   2663             updated_at: "2026-04-18T10:00:00Z".to_owned(),
   2664         };
   2665         let summary = ProductsListSummary {
   2666             total_products: 1,
   2667             live_products: 0,
   2668             draft_products: 1,
   2669             need_attention_products: 1,
   2670         };
   2671         let projection = ProductsListProjection {
   2672             summary: summary.clone(),
   2673             rows: vec![row.clone()],
   2674         };
   2675 
   2676         assert_eq!(ProductAttentionState::LowStock.storage_key(), "low_stock");
   2677         assert!(ProductAttentionState::LowStock.requires_attention());
   2678         assert!(!ProductAttentionState::Healthy.requires_attention());
   2679         assert_eq!(
   2680             ProductAvailabilityState::MissingWindow.storage_key(),
   2681             "missing_window"
   2682         );
   2683         assert_eq!(ProductStockState::SoldOut.storage_key(), "sold_out");
   2684         assert!(ProductStockState::SoldOut.requires_attention());
   2685         assert!(!ProductStockState::InStock.requires_attention());
   2686         assert!(row.requires_attention());
   2687         assert!(summary.has_products());
   2688         assert!(!projection.is_empty());
   2689         assert_eq!(projection.rows[0].availability.label, "Missing window");
   2690     }
   2691 
   2692     #[test]
   2693     fn product_editor_publish_blockers_are_explicit_and_minimal() {
   2694         let empty_draft = ProductEditorDraft::default();
   2695         let ready_draft = ProductEditorDraft {
   2696             title: "Heirloom tomatoes".to_owned(),
   2697             subtitle: "Brandywine".to_owned(),
   2698             category: "vegetables".to_owned(),
   2699             unit_label: "lb".to_owned(),
   2700             price_minor_units: Some(450),
   2701             price_currency: "USD".to_owned(),
   2702             stock_quantity: Some(12),
   2703             availability_window_id: Some(super::FulfillmentWindowId::new()),
   2704             status: ProductStatus::Draft,
   2705         };
   2706 
   2707         assert_eq!(
   2708             empty_draft.publish_blockers(),
   2709             vec![
   2710                 ProductPublishBlocker::AddProductName,
   2711                 ProductPublishBlocker::ChooseCategory,
   2712                 ProductPublishBlocker::ChooseUnit,
   2713                 ProductPublishBlocker::SetPrice,
   2714                 ProductPublishBlocker::SetStock,
   2715                 ProductPublishBlocker::AttachAvailability,
   2716             ]
   2717         );
   2718         assert_eq!(
   2719             ProductPublishBlocker::AttachAvailability.storage_key(),
   2720             "attach_availability"
   2721         );
   2722         assert_eq!(empty_draft.price_currency, "USD");
   2723         assert!(!empty_draft.is_publish_ready());
   2724         assert!(ready_draft.is_publish_ready());
   2725         assert!(ready_draft.publish_blockers().is_empty());
   2726     }
   2727 
   2728     #[test]
   2729     fn order_status_filter_and_primary_action_storage_keys_are_stable() {
   2730         assert_eq!(OrderStatus::NeedsAction.storage_key(), "needs_action");
   2731         assert_eq!(OrderStatus::Scheduled.storage_key(), "scheduled");
   2732         assert_eq!(OrderStatus::Packed.storage_key(), "packed");
   2733         assert_eq!(OrderStatus::Completed.storage_key(), "completed");
   2734         assert_eq!(OrderStatus::Declined.storage_key(), "declined");
   2735         assert_eq!(OrderStatus::NeedsReview.storage_key(), "needs_review");
   2736         assert_eq!(BuyerOrderStatus::Placed.storage_key(), "placed");
   2737         assert_eq!(BuyerOrderStatus::Scheduled.storage_key(), "scheduled");
   2738         assert_eq!(BuyerOrderStatus::Ready.storage_key(), "ready");
   2739         assert_eq!(BuyerOrderStatus::Completed.storage_key(), "completed");
   2740         assert_eq!(BuyerOrderStatus::Declined.storage_key(), "declined");
   2741         assert_eq!(BuyerOrderStatus::NeedsReview.storage_key(), "needs_review");
   2742         assert_eq!(
   2743             BuyerOrderStatus::from(OrderStatus::NeedsAction),
   2744             BuyerOrderStatus::Placed
   2745         );
   2746         assert_eq!(
   2747             BuyerOrderStatus::from(OrderStatus::Packed),
   2748             BuyerOrderStatus::Ready
   2749         );
   2750         assert_eq!(
   2751             BuyerOrderStatus::from(OrderStatus::Declined),
   2752             BuyerOrderStatus::Declined
   2753         );
   2754         assert_eq!(
   2755             BuyerOrderStatus::from(OrderStatus::NeedsReview),
   2756             BuyerOrderStatus::NeedsReview
   2757         );
   2758 
   2759         assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction);
   2760         assert_eq!(OrdersFilter::All.storage_key(), "all");
   2761         assert_eq!(OrdersFilter::NeedsAction.storage_key(), "needs_action");
   2762         assert_eq!(OrdersFilter::Scheduled.storage_key(), "scheduled");
   2763         assert_eq!(OrdersFilter::Packed.storage_key(), "packed");
   2764         assert_eq!(OrdersFilter::Completed.storage_key(), "completed");
   2765 
   2766         assert_eq!(OrderPrimaryAction::Review.storage_key(), "review");
   2767     }
   2768 
   2769     fn test_decimal(raw: &str) -> RadrootsCoreDecimal {
   2770         raw.parse().expect("test decimal should parse")
   2771     }
   2772 
   2773     fn test_usd(raw: &str) -> RadrootsCoreMoney {
   2774         RadrootsCoreMoney::new(test_decimal(raw), RadrootsCoreCurrency::USD)
   2775     }
   2776 
   2777     fn test_order_id(raw: &str) -> RadrootsOrderId {
   2778         raw.parse().expect("test order id should parse")
   2779     }
   2780 
   2781     fn test_quote_id(raw: &str) -> RadrootsOrderQuoteId {
   2782         raw.parse().expect("test quote id should parse")
   2783     }
   2784 
   2785     fn test_bin_id(raw: &str) -> RadrootsInventoryBinId {
   2786         raw.parse().expect("test bin id should parse")
   2787     }
   2788 
   2789     fn test_event_id(raw: &str) -> RadrootsEventId {
   2790         raw.parse().expect("test event id should parse")
   2791     }
   2792 
   2793     fn test_pubkey(raw: &str) -> RadrootsPublicKey {
   2794         raw.parse().expect("test pubkey should parse")
   2795     }
   2796 
   2797     fn test_listing_addr(raw: &str) -> RadrootsListingAddress {
   2798         raw.parse().expect("test listing address should parse")
   2799     }
   2800 
   2801     fn test_trade_economics() -> RadrootsOrderEconomics {
   2802         RadrootsOrderEconomics {
   2803             quote_id: test_quote_id("quote-1"),
   2804             quote_version: 2,
   2805             pricing_basis: RadrootsOrderPricingBasis::ListingEvent,
   2806             currency: RadrootsCoreCurrency::USD,
   2807             items: vec![RadrootsOrderEconomicItem {
   2808                 bin_id: test_bin_id("bin-1"),
   2809                 bin_count: 2,
   2810                 quantity_amount: test_decimal("1"),
   2811                 quantity_unit: RadrootsCoreUnit::Each,
   2812                 unit_price_amount: test_decimal("6.17"),
   2813                 unit_price_currency: RadrootsCoreCurrency::USD,
   2814                 line_subtotal: test_usd("12.34"),
   2815             }],
   2816             discounts: Vec::new(),
   2817             adjustments: Vec::new(),
   2818             subtotal: test_usd("12.34"),
   2819             discount_total: test_usd("0"),
   2820             adjustment_total: test_usd("0"),
   2821             total: test_usd("12.34"),
   2822         }
   2823     }
   2824 
   2825     fn test_active_order_projection(status: RadrootsOrderStatus) -> RadrootsOrderProjection {
   2826         RadrootsOrderProjection {
   2827             order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"),
   2828             status,
   2829             request_event_id: Some(test_event_id(
   2830                 "1111111111111111111111111111111111111111111111111111111111111111",
   2831             )),
   2832             decision_event_id: Some(test_event_id(
   2833                 "2222222222222222222222222222222222222222222222222222222222222222",
   2834             )),
   2835             cancellation_event_id: None,
   2836             lifecycle_terminal: false,
   2837             economics: Some(test_trade_economics()),
   2838             agreement_event_id: Some(test_event_id(
   2839                 "2222222222222222222222222222222222222222222222222222222222222222",
   2840             )),
   2841             pending_revision_event_id: None,
   2842             listing_addr: Some(test_listing_addr(
   2843                 "30402:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:AAAAAAAAAAAAAAAAAAAAAg",
   2844             )),
   2845             buyer_pubkey: Some(test_pubkey(
   2846                 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
   2847             )),
   2848             seller_pubkey: Some(test_pubkey(
   2849                 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
   2850             )),
   2851             last_event_id: Some(test_event_id(
   2852                 "3333333333333333333333333333333333333333333333333333333333333333",
   2853             )),
   2854             issues: Vec::new(),
   2855         }
   2856     }
   2857 
   2858     #[test]
   2859     fn trade_workflow_projection_maps_shared_active_order_projection_to_product_axes() {
   2860         assert_eq!(
   2861             TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Requested),
   2862             TradeAgreementStatus::Ordered
   2863         );
   2864         assert_eq!(
   2865             TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Accepted),
   2866             TradeAgreementStatus::Confirmed
   2867         );
   2868         assert_eq!(
   2869             TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Invalid),
   2870             TradeAgreementStatus::NeedsReview
   2871         );
   2872         assert_eq!(
   2873             TradeRevisionStatus::try_from_storage_key("none"),
   2874             Ok(TradeRevisionStatus::None)
   2875         );
   2876         assert_eq!(
   2877             TradeRevisionStatus::try_from_storage_key("change_proposed"),
   2878             Ok(TradeRevisionStatus::ChangeProposed)
   2879         );
   2880         assert_eq!(
   2881             TradeRevisionStatus::try_from_storage_key("updated"),
   2882             Ok(TradeRevisionStatus::Updated)
   2883         );
   2884         assert_eq!(
   2885             TradeRevisionStatus::try_from_storage_key("kept_as_placed"),
   2886             Ok(TradeRevisionStatus::KeptAsPlaced)
   2887         );
   2888         assert_eq!(
   2889             TradeRevisionStatus::try_from_storage_key("proposed")
   2890                 .expect_err("shared reducer key should not parse as app revision key")
   2891                 .value(),
   2892             "proposed"
   2893         );
   2894         assert!(
   2895             TradeRevisionStatus::try_from_storage_key(" none ").is_err(),
   2896             "storage keys must parse exactly"
   2897         );
   2898 
   2899         let order_id = OrderId::new();
   2900         let active_order = test_active_order_projection(RadrootsOrderStatus::Accepted);
   2901         let projection = TradeWorkflowProjection::from_active_order_projection(
   2902             order_id,
   2903             &active_order,
   2904             TradeRevisionStatus::Updated,
   2905             TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents),
   2906         );
   2907         assert_eq!(projection.order_id, order_id);
   2908         assert_eq!(projection.agreement, TradeAgreementStatus::Confirmed);
   2909         assert_eq!(projection.revision, TradeRevisionStatus::Updated);
   2910         assert_eq!(projection.inventory, TradeInventoryStatus::Reserved);
   2911         assert_eq!(projection.economics.total_minor_units, Some(1234));
   2912         assert_eq!(projection.economics.currency_code.as_deref(), Some("USD"));
   2913         assert_eq!(
   2914             projection.provenance,
   2915             TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents)
   2916                 .with_last_event_id(Some(
   2917                     "3333333333333333333333333333333333333333333333333333333333333333".to_owned()
   2918                 ))
   2919         );
   2920         assert_eq!(
   2921             order_status_from_active_order_projection(&active_order),
   2922             Some(OrderStatus::Scheduled)
   2923         );
   2924 
   2925         let requested_order = test_active_order_projection(RadrootsOrderStatus::Requested);
   2926         let requested_projection = TradeWorkflowProjection::from_active_order_projection(
   2927             order_id,
   2928             &requested_order,
   2929             TradeRevisionStatus::None,
   2930             TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents),
   2931         );
   2932         assert_eq!(
   2933             requested_projection.agreement,
   2934             TradeAgreementStatus::Ordered
   2935         );
   2936         assert_eq!(
   2937             requested_projection.inventory,
   2938             TradeInventoryStatus::NeedsReview
   2939         );
   2940     }
   2941 
   2942     #[test]
   2943     fn validation_receipt_projection_maps_shared_receipt_metadata_passively() {
   2944         assert_eq!(
   2945             TradeValidationReceiptResult::from_validation_receipt_result(
   2946                 RadrootsValidationReceiptResult::Valid
   2947             ),
   2948             TradeValidationReceiptResult::Valid
   2949         );
   2950         assert_eq!(
   2951             TradeValidationReceiptResult::from_validation_receipt_result(
   2952                 RadrootsValidationReceiptResult::Invalid
   2953             ),
   2954             TradeValidationReceiptResult::NeedsReview
   2955         );
   2956         assert_eq!(
   2957             TradeValidationReceiptType::from_validation_receipt_type(
   2958                 RadrootsValidationReceiptType::TradeTransition
   2959             ),
   2960             TradeValidationReceiptType::TradeTransition
   2961         );
   2962         assert_eq!(
   2963             TradeValidationReceiptProofSystem::from_validation_receipt_proof_system(
   2964                 RadrootsValidationReceiptProofSystem::Sp1Compressed
   2965             ),
   2966             TradeValidationReceiptProofSystem::Sp1Compressed
   2967         );
   2968         assert_eq!(TradeValidationReceiptResult::Valid.storage_key(), "valid");
   2969         assert_eq!(
   2970             TradeValidationReceiptResult::NeedsReview.storage_key(),
   2971             "needs_review"
   2972         );
   2973         assert_eq!(
   2974             TradeValidationReceiptType::TradeTransition.storage_key(),
   2975             "trade_transition"
   2976         );
   2977         assert_eq!(
   2978             TradeValidationReceiptProofSystem::Sp1Compressed.storage_key(),
   2979             "sp1_compressed"
   2980         );
   2981         assert_eq!(
   2982             TradeValidationReceiptResult::NeedsReview.label_key_id(),
   2983             "messages.trade.validation.result.needs_review"
   2984         );
   2985         assert_eq!(
   2986             TradeValidationReceiptType::InventoryState.label_key_id(),
   2987             "messages.trade.validation.type.inventory_state"
   2988         );
   2989         assert_eq!(
   2990             TradeValidationReceiptProofSystem::Sp1Compressed.label_key_id(),
   2991             "messages.trade.validation.proof.sp1_compressed"
   2992         );
   2993     }
   2994 
   2995     #[test]
   2996     fn trade_workflow_projection_uses_localization_key_ids_for_visible_status_labels() {
   2997         assert_eq!(
   2998             TradeAgreementStatus::from_active_order_status(&RadrootsOrderStatus::Requested)
   2999                 .storage_key(),
   3000             "ordered"
   3001         );
   3002         assert_eq!(TradeAgreementStatus::Ordered.storage_key(), "ordered");
   3003         assert_eq!(
   3004             TradeRevisionStatus::KeptAsPlaced.storage_key(),
   3005             "kept_as_placed"
   3006         );
   3007         assert_eq!(TradeInventoryStatus::Reserved.storage_key(), "reserved");
   3008         assert_eq!(
   3009             TradeWorkflowSource::LocalEvents.storage_key(),
   3010             "local_events"
   3011         );
   3012 
   3013         assert_eq!(
   3014             TradeAgreementStatus::Ordered.label_key_id(),
   3015             "messages.trade.workflow.agreement.ordered"
   3016         );
   3017         assert_eq!(
   3018             TradeAgreementStatus::NeedsReview.label_key_id(),
   3019             "messages.trade.workflow.agreement.needs_review"
   3020         );
   3021         assert_eq!(
   3022             TradeRevisionStatus::ChangeProposed.label_key_id(),
   3023             "messages.trade.workflow.revision.change_proposed"
   3024         );
   3025         assert_eq!(
   3026             TradeInventoryStatus::SoldOut.label_key_id(),
   3027             "messages.trade.workflow.inventory.sold_out"
   3028         );
   3029         assert_eq!(
   3030             TradeWorkflowSource::Cli.label_key_id(),
   3031             "messages.trade.workflow.provenance.cli"
   3032         );
   3033     }
   3034 
   3035     #[test]
   3036     fn orders_and_pack_day_query_state_defaults_are_frozen() {
   3037         assert_eq!(
   3038             OrdersScreenQueryState::default(),
   3039             OrdersScreenQueryState {
   3040                 filter: OrdersFilter::NeedsAction,
   3041                 fulfillment_window_id: None,
   3042             }
   3043         );
   3044         assert_eq!(
   3045             PackDayScreenQueryState::default(),
   3046             PackDayScreenQueryState {
   3047                 fulfillment_window_id: None,
   3048             }
   3049         );
   3050     }
   3051 
   3052     #[test]
   3053     fn pack_day_export_print_and_host_handoff_contracts_are_frozen_for_v1() {
   3054         assert_eq!(
   3055             PackDayExportArtifactKind::all_v1(),
   3056             [
   3057                 PackDayExportArtifactKind::PackSheet,
   3058                 PackDayExportArtifactKind::PickupRoster,
   3059                 PackDayExportArtifactKind::CustomerLabels,
   3060             ]
   3061         );
   3062         assert_eq!(
   3063             PackDayExportArtifactKind::PackSheet.storage_key(),
   3064             "pack_sheet"
   3065         );
   3066         assert_eq!(
   3067             PackDayExportArtifactKind::PackSheet.file_name(),
   3068             "pack_sheet.txt"
   3069         );
   3070         assert_eq!(
   3071             PackDayExportArtifactKind::PickupRoster.file_name(),
   3072             "pickup_roster.txt"
   3073         );
   3074         assert_eq!(
   3075             PackDayExportArtifactKind::CustomerLabels.file_name(),
   3076             "customer_labels.txt"
   3077         );
   3078         assert_eq!(PackDayExportStatus::default(), PackDayExportStatus::Idle);
   3079         assert_eq!(PackDayExportStatus::Running.storage_key(), "running");
   3080         assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded");
   3081         assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed");
   3082         assert_eq!(
   3083             PackDayPrintKind::all_v1(),
   3084             [
   3085                 PackDayPrintKind::PrintPackSheet,
   3086                 PackDayPrintKind::PrintPickupRoster,
   3087                 PackDayPrintKind::PrintCustomerLabels,
   3088             ]
   3089         );
   3090         assert_eq!(
   3091             PackDayPrintKind::PrintPackSheet.storage_key(),
   3092             "print_pack_sheet"
   3093         );
   3094         assert_eq!(
   3095             PackDayPrintKind::PrintPickupRoster.storage_key(),
   3096             "print_pickup_roster"
   3097         );
   3098         assert_eq!(
   3099             PackDayPrintKind::PrintCustomerLabels.storage_key(),
   3100             "print_customer_labels"
   3101         );
   3102         assert_eq!(
   3103             PackDayPrintKind::PrintPackSheet.artifact_kind(),
   3104             PackDayExportArtifactKind::PackSheet
   3105         );
   3106         assert_eq!(
   3107             PackDayPrintKind::PrintPickupRoster.artifact_kind(),
   3108             PackDayExportArtifactKind::PickupRoster
   3109         );
   3110         assert_eq!(
   3111             PackDayPrintKind::PrintCustomerLabels.artifact_kind(),
   3112             PackDayExportArtifactKind::CustomerLabels
   3113         );
   3114         assert_eq!(PackDayPrintKind::PrintPackSheet.label_stock(), None);
   3115         assert_eq!(PackDayPrintKind::PrintPickupRoster.label_stock(), None);
   3116         assert_eq!(
   3117             PackDayPrintKind::PrintCustomerLabels.label_stock(),
   3118             Some(PackDayPrintLabelStock::Avery5160Letter30Up)
   3119         );
   3120         assert_eq!(
   3121             PackDayPrintLabelStock::all_v1(),
   3122             [PackDayPrintLabelStock::Avery5160Letter30Up]
   3123         );
   3124         assert_eq!(
   3125             PackDayPrintLabelStock::Avery5160Letter30Up.storage_key(),
   3126             "avery_5160_letter_30_up"
   3127         );
   3128         assert_eq!(
   3129             PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(),
   3130             "customer_labels_avery_5160_overflow"
   3131         );
   3132         assert_eq!(
   3133             PackDayBatchPrintArtifact::all_v1(),
   3134             [
   3135                 PackDayBatchPrintArtifact {
   3136                     print_kind: PackDayPrintKind::PrintPackSheet,
   3137                     artifact_kind: PackDayExportArtifactKind::PackSheet,
   3138                     label_stock: None,
   3139                 },
   3140                 PackDayBatchPrintArtifact {
   3141                     print_kind: PackDayPrintKind::PrintPickupRoster,
   3142                     artifact_kind: PackDayExportArtifactKind::PickupRoster,
   3143                     label_stock: None,
   3144                 },
   3145                 PackDayBatchPrintArtifact {
   3146                     print_kind: PackDayPrintKind::PrintCustomerLabels,
   3147                     artifact_kind: PackDayExportArtifactKind::CustomerLabels,
   3148                     label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up),
   3149                 },
   3150             ]
   3151         );
   3152         assert_eq!(
   3153             PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintCustomerLabels),
   3154             PackDayBatchPrintArtifact {
   3155                 print_kind: PackDayPrintKind::PrintCustomerLabels,
   3156                 artifact_kind: PackDayExportArtifactKind::CustomerLabels,
   3157                 label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up),
   3158             }
   3159         );
   3160         assert_eq!(
   3161             PackDayBatchPrintFailureKind::Preflight.storage_key(),
   3162             "preflight"
   3163         );
   3164         assert_eq!(
   3165             PackDayBatchPrintFailureKind::QueueLaunch.storage_key(),
   3166             "queue_launch"
   3167         );
   3168         assert_eq!(
   3169             PackDayBatchPrintFailureKind::QueueExit.storage_key(),
   3170             "queue_exit"
   3171         );
   3172         assert_eq!(
   3173             PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(),
   3174             "customer_labels_avery_5160_overflow"
   3175         );
   3176         assert_eq!(
   3177             PackDayBatchPrintStatus::default(),
   3178             PackDayBatchPrintStatus::Idle
   3179         );
   3180         assert_eq!(PackDayBatchPrintStatus::Running.storage_key(), "running");
   3181         assert_eq!(
   3182             PackDayBatchPrintStatus::Succeeded.storage_key(),
   3183             "succeeded"
   3184         );
   3185         assert_eq!(PackDayBatchPrintStatus::Failed.storage_key(), "failed");
   3186         assert_eq!(PackDayPrintStatus::default(), PackDayPrintStatus::Idle);
   3187         assert_eq!(PackDayPrintStatus::Running.storage_key(), "running");
   3188         assert_eq!(PackDayPrintStatus::Succeeded.storage_key(), "succeeded");
   3189         assert_eq!(PackDayPrintStatus::Failed.storage_key(), "failed");
   3190         assert_eq!(
   3191             PackDayHostHandoffKind::all_v1(),
   3192             [
   3193                 PackDayHostHandoffKind::RevealBundle,
   3194                 PackDayHostHandoffKind::OpenPackSheet,
   3195                 PackDayHostHandoffKind::OpenPickupRoster,
   3196                 PackDayHostHandoffKind::OpenCustomerLabels,
   3197             ]
   3198         );
   3199         assert_eq!(
   3200             PackDayHostHandoffKind::RevealBundle.storage_key(),
   3201             "reveal_bundle"
   3202         );
   3203         assert_eq!(
   3204             PackDayHostHandoffKind::OpenPackSheet.storage_key(),
   3205             "open_pack_sheet"
   3206         );
   3207         assert_eq!(
   3208             PackDayHostHandoffKind::OpenPickupRoster.storage_key(),
   3209             "open_pickup_roster"
   3210         );
   3211         assert_eq!(
   3212             PackDayHostHandoffKind::OpenCustomerLabels.storage_key(),
   3213             "open_customer_labels"
   3214         );
   3215         assert_eq!(PackDayHostHandoffKind::RevealBundle.artifact_kind(), None);
   3216         assert_eq!(
   3217             PackDayHostHandoffKind::OpenPackSheet.artifact_kind(),
   3218             Some(PackDayExportArtifactKind::PackSheet)
   3219         );
   3220         assert_eq!(
   3221             PackDayHostHandoffKind::OpenPickupRoster.artifact_kind(),
   3222             Some(PackDayExportArtifactKind::PickupRoster)
   3223         );
   3224         assert_eq!(
   3225             PackDayHostHandoffKind::OpenCustomerLabels.artifact_kind(),
   3226             Some(PackDayExportArtifactKind::CustomerLabels)
   3227         );
   3228         assert_eq!(
   3229             PackDayHostHandoffStatus::default(),
   3230             PackDayHostHandoffStatus::Idle
   3231         );
   3232         assert_eq!(PackDayHostHandoffStatus::Running.storage_key(), "running");
   3233         assert_eq!(
   3234             PackDayHostHandoffStatus::Succeeded.storage_key(),
   3235             "succeeded"
   3236         );
   3237         assert_eq!(PackDayHostHandoffStatus::Failed.storage_key(), "failed");
   3238     }
   3239 
   3240     #[test]
   3241     fn pack_day_output_order_state_freezes_the_v1_status_subset() {
   3242         assert_eq!(
   3243             PackDayOutputOrderState::all_v1(),
   3244             [
   3245                 PackDayOutputOrderState::NeedsAction,
   3246                 PackDayOutputOrderState::Scheduled,
   3247                 PackDayOutputOrderState::Packed,
   3248             ]
   3249         );
   3250         assert_eq!(
   3251             PackDayOutputOrderState::from_order_status(OrderStatus::NeedsAction),
   3252             Some(PackDayOutputOrderState::NeedsAction)
   3253         );
   3254         assert_eq!(
   3255             PackDayOutputOrderState::from_order_status(OrderStatus::Scheduled),
   3256             Some(PackDayOutputOrderState::Scheduled)
   3257         );
   3258         assert_eq!(
   3259             PackDayOutputOrderState::from_order_status(OrderStatus::Packed),
   3260             Some(PackDayOutputOrderState::Packed)
   3261         );
   3262         assert_eq!(
   3263             PackDayOutputOrderState::from_order_status(OrderStatus::Completed),
   3264             None
   3265         );
   3266         assert_eq!(
   3267             PackDayOutputOrderState::from_order_status(OrderStatus::Declined),
   3268             None
   3269         );
   3270         assert_eq!(
   3271             PackDayOutputOrderState::from_order_status(OrderStatus::NeedsReview),
   3272             None
   3273         );
   3274         assert_eq!(
   3275             OrderStatus::from(PackDayOutputOrderState::Packed),
   3276             OrderStatus::Packed
   3277         );
   3278     }
   3279 
   3280     #[test]
   3281     fn pack_day_output_source_keeps_export_truth_out_of_ui_display_strings() {
   3282         let farm_id = FarmId::new();
   3283         let fulfillment_window_id = FulfillmentWindowId::new();
   3284         let order_id = OrderId::new();
   3285         let screen_row = PackDayPackListRow {
   3286             title: "Salad mix".to_owned(),
   3287             quantity_display: "Casey: 2 bags".to_owned(),
   3288         };
   3289         let source = PackDayOutputSource {
   3290             fulfillment_window: PackDayOutputWindow {
   3291                 fulfillment_window_id,
   3292                 farm_id,
   3293                 farm_display_name: "Willow farm".to_owned(),
   3294                 pickup_location_label: Some("North barn".to_owned()),
   3295                 starts_at: "2026-04-23T16:00:00Z".to_owned(),
   3296                 ends_at: "2026-04-23T19:00:00Z".to_owned(),
   3297             },
   3298             totals_by_product: vec![PackDayOutputProductTotal {
   3299                 title: "Salad mix".to_owned(),
   3300                 quantity: PackDayOutputQuantity::new(2, "bags"),
   3301             }],
   3302             pack_list: vec![PackDayOutputPackListEntry {
   3303                 order_id,
   3304                 order_number: "R-1001".to_owned(),
   3305                 customer_display_name: "Casey".to_owned(),
   3306                 order_state: PackDayOutputOrderState::Scheduled,
   3307                 title: "Salad mix".to_owned(),
   3308                 quantity: PackDayOutputQuantity::new(2, "bags"),
   3309             }],
   3310             pickup_roster: vec![PackDayOutputCustomerOrder {
   3311                 order_id,
   3312                 order_number: "R-1001".to_owned(),
   3313                 customer_display_name: "Casey".to_owned(),
   3314                 order_state: PackDayOutputOrderState::Scheduled,
   3315             }],
   3316         };
   3317 
   3318         assert_eq!(screen_row.quantity_display, "Casey: 2 bags");
   3319         assert!(!source.is_empty());
   3320         assert_eq!(source.pack_list[0].customer_display_name, "Casey");
   3321         assert_eq!(source.pack_list[0].quantity.value, 2);
   3322         assert_eq!(source.pack_list[0].quantity.unit_label, "bags");
   3323         assert_eq!(
   3324             source.pickup_roster[0].order_state.storage_key(),
   3325             "scheduled"
   3326         );
   3327     }
   3328 
   3329     #[test]
   3330     fn pack_day_export_bundle_tracks_output_directory_and_artifacts() {
   3331         let fulfillment_window_id = FulfillmentWindowId::new();
   3332         let bundle = PackDayExportBundle {
   3333             fulfillment_window_id,
   3334             export_instance_id: PackDayExportInstanceId::new(),
   3335             generated_at_utc: "2026-04-23T15:00:00Z".to_owned(),
   3336             bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(),
   3337             artifacts: vec![
   3338                 PackDayExportArtifact {
   3339                     kind: PackDayExportArtifactKind::PackSheet,
   3340                     relative_path: "pack_sheet.txt".to_owned(),
   3341                 },
   3342                 PackDayExportArtifact {
   3343                     kind: PackDayExportArtifactKind::PickupRoster,
   3344                     relative_path: "pickup_roster.txt".to_owned(),
   3345                 },
   3346             ],
   3347         };
   3348 
   3349         assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id);
   3350         assert_eq!(bundle.artifact_count(), 2);
   3351         assert!(bundle.includes_artifact(PackDayExportArtifactKind::PackSheet));
   3352         assert!(bundle.includes_artifact(PackDayExportArtifactKind::PickupRoster));
   3353         assert!(!bundle.includes_artifact(PackDayExportArtifactKind::CustomerLabels));
   3354     }
   3355 
   3356     #[test]
   3357     fn orders_and_pack_day_projections_hold_truthful_execution_data() {
   3358         let fulfillment_window_id = super::FulfillmentWindowId::new();
   3359         let farm_id = FarmId::new();
   3360         let order_id = super::OrderId::new();
   3361         let order_economics = TradeEconomicsProjection {
   3362             subtotal_minor_units: Some(1300),
   3363             total_minor_units: Some(1300),
   3364             currency_code: Some("USD".to_owned()),
   3365             ..TradeEconomicsProjection::default()
   3366         };
   3367         let orders_list = OrdersListProjection {
   3368             summary: OrdersListSummary {
   3369                 total_orders: 3,
   3370                 needs_action_orders: 1,
   3371                 scheduled_orders: 1,
   3372                 packed_orders: 1,
   3373             },
   3374             rows: vec![OrdersListRow {
   3375                 order_id,
   3376                 farm_id,
   3377                 fulfillment_window_id: Some(fulfillment_window_id),
   3378                 order_number: "R-1001".to_owned(),
   3379                 customer_display_name: "Casey".to_owned(),
   3380                 fulfillment_window_label: Some("Wednesday pickup".to_owned()),
   3381                 pickup_location_label: Some("North barn".to_owned()),
   3382                 status: OrderStatus::Scheduled,
   3383                 workflow: TradeWorkflowProjection::from_order_status(
   3384                     order_id,
   3385                     OrderStatus::Scheduled,
   3386                 ),
   3387                 primary_action: None,
   3388             }],
   3389         };
   3390         let order_detail = OrderDetailProjection {
   3391             order_id,
   3392             farm_id,
   3393             order_number: "R-1001".to_owned(),
   3394             customer_display_name: "Casey".to_owned(),
   3395             status: OrderStatus::Scheduled,
   3396             fulfillment_window_id: Some(fulfillment_window_id),
   3397             fulfillment_window_label: Some("Wednesday pickup".to_owned()),
   3398             pickup_location_label: Some("North barn".to_owned()),
   3399             items: vec![OrderDetailItemRow {
   3400                 title: "Salad mix".to_owned(),
   3401                 quantity_display: "2 bags".to_owned(),
   3402                 unit_price: Some(ProductPricePresentation {
   3403                     amount_minor_units: 650,
   3404                     currency_code: "USD".to_owned(),
   3405                     unit_label: "bag".to_owned(),
   3406                 }),
   3407                 line_total_minor_units: Some(1300),
   3408             }],
   3409             economics: order_economics.clone(),
   3410             workflow: TradeWorkflowProjection::from_order_status(order_id, OrderStatus::Scheduled)
   3411                 .with_economics(order_economics),
   3412             validation_receipts: Vec::new(),
   3413             primary_action: None,
   3414         };
   3415         let pack_day = PackDayProjection {
   3416             fulfillment_window: Some(super::FulfillmentWindowSummary {
   3417                 fulfillment_window_id,
   3418                 farm_id,
   3419                 starts_at: "2026-04-23T16:00:00Z".to_owned(),
   3420                 ends_at: "2026-04-23T19:00:00Z".to_owned(),
   3421             }),
   3422             totals_by_product: vec![PackDayProductTotalRow {
   3423                 title: "Salad mix".to_owned(),
   3424                 quantity_display: "8 bags".to_owned(),
   3425             }],
   3426             pack_list: vec![PackDayPackListRow {
   3427                 title: "Salad mix".to_owned(),
   3428                 quantity_display: "Casey: 2 bags".to_owned(),
   3429             }],
   3430             pickup_roster: vec![PackDayRosterRow {
   3431                 order_id,
   3432                 order_number: "R-1001".to_owned(),
   3433                 customer_display_name: "Casey".to_owned(),
   3434             }],
   3435             reminders: ReminderFeedProjection::default(),
   3436         };
   3437 
   3438         assert!(orders_list.summary.has_orders());
   3439         assert!(!orders_list.is_empty());
   3440         assert_eq!(orders_list.rows[0].primary_action, None);
   3441         assert_eq!(
   3442             orders_list.rows[0].workflow.agreement,
   3443             TradeAgreementStatus::Confirmed
   3444         );
   3445         assert_eq!(order_detail.items[0].quantity_display, "2 bags");
   3446         assert_eq!(
   3447             order_detail.workflow.inventory,
   3448             TradeInventoryStatus::Reserved
   3449         );
   3450         assert!(!pack_day.is_empty());
   3451         assert_eq!(pack_day.pickup_roster[0].order_number, "R-1001");
   3452     }
   3453 
   3454     #[test]
   3455     fn buyer_marketplace_projections_hold_guest_capable_contract_data() {
   3456         let farm_id = FarmId::new();
   3457         let product_id = super::ProductId::new();
   3458         let order_id = super::OrderId::new();
   3459         let buyer_order_economics = TradeEconomicsProjection {
   3460             subtotal_minor_units: Some(1300),
   3461             total_minor_units: Some(1300),
   3462             currency_code: Some("USD".to_owned()),
   3463             ..TradeEconomicsProjection::default()
   3464         };
   3465         let listing = BuyerListingRow {
   3466             product_id,
   3467             farm_id,
   3468             farm_display_name: "Cedar Grove Farm".to_owned(),
   3469             listing_relays: vec!["wss://relay.example".to_owned()],
   3470             title: "Spring salad mix".to_owned(),
   3471             subtitle: Some("Tender leaves".to_owned()),
   3472             price: ProductPricePresentation {
   3473                 amount_minor_units: 650,
   3474                 currency_code: "USD".to_owned(),
   3475                 unit_label: "bag".to_owned(),
   3476             },
   3477             availability: ProductAvailabilitySummary {
   3478                 state: ProductAvailabilityState::Scheduled,
   3479                 label: "Thursday pickup".to_owned(),
   3480             },
   3481             stock: ProductStockSummary {
   3482                 quantity: Some(8),
   3483                 unit_label: Some("bag".to_owned()),
   3484                 state: ProductStockState::InStock,
   3485             },
   3486             fulfillment_methods: BTreeSet::from([FarmOrderMethod::Pickup]),
   3487             next_fulfillment_window_label: Some("Thursday pickup".to_owned()),
   3488         };
   3489         let listings = BuyerListingsProjection {
   3490             rows: vec![listing.clone()],
   3491         };
   3492         let cart = BuyerCartProjection {
   3493             farm_id: Some(farm_id),
   3494             farm_display_name: Some("Cedar Grove Farm".to_owned()),
   3495             lines: vec![BuyerCartLineProjection {
   3496                 product_id,
   3497                 farm_id,
   3498                 farm_display_name: "Cedar Grove Farm".to_owned(),
   3499                 title: "Spring salad mix".to_owned(),
   3500                 quantity: 2,
   3501                 unit_price: ProductPricePresentation {
   3502                     amount_minor_units: 650,
   3503                     currency_code: "USD".to_owned(),
   3504                     unit_label: "bag".to_owned(),
   3505                 },
   3506                 line_total_minor_units: 1300,
   3507                 fulfillment_summary: "Thursday pickup".to_owned(),
   3508             }],
   3509             subtotal_minor_units: Some(1300),
   3510             currency_code: Some("USD".to_owned()),
   3511             replace_confirmation: None,
   3512         };
   3513         let order_review = BuyerOrderReviewProjection {
   3514             draft: BuyerOrderReviewDraft {
   3515                 name: "Casey Buyer".to_owned(),
   3516                 email: "casey@example.com".to_owned(),
   3517                 phone: String::new(),
   3518                 order_note: "Leave by the cooler".to_owned(),
   3519             },
   3520             summary: BuyerOrderReviewSummaryProjection {
   3521                 farm_display_name: Some("Cedar Grove Farm".to_owned()),
   3522                 fulfillment_summary: Some("Thursday pickup".to_owned()),
   3523                 line_count: 1,
   3524                 subtotal_minor_units: Some(1300),
   3525                 currency_code: Some("USD".to_owned()),
   3526             },
   3527             can_place_order: true,
   3528             place_order_disabled_reason: None,
   3529         };
   3530         let orders = BuyerOrdersProjection {
   3531             rows: vec![BuyerOrdersListRow {
   3532                 order_id,
   3533                 farm_id,
   3534                 order_number: "R-2001".to_owned(),
   3535                 farm_display_name: "Cedar Grove Farm".to_owned(),
   3536                 fulfillment_summary: "Thursday pickup".to_owned(),
   3537                 status: BuyerOrderStatus::Scheduled,
   3538                 workflow: TradeWorkflowProjection::from_buyer_order_status(
   3539                     order_id,
   3540                     BuyerOrderStatus::Scheduled,
   3541                 ),
   3542                 repeat_demand: None,
   3543             }],
   3544         };
   3545         let order_detail = BuyerOrderDetailProjection {
   3546             order_id,
   3547             farm_id,
   3548             order_number: "R-2001".to_owned(),
   3549             farm_display_name: "Cedar Grove Farm".to_owned(),
   3550             fulfillment_summary: "Thursday pickup".to_owned(),
   3551             status: BuyerOrderStatus::Scheduled,
   3552             items: vec![OrderDetailItemRow {
   3553                 title: "Spring salad mix".to_owned(),
   3554                 quantity_display: "2 bags".to_owned(),
   3555                 unit_price: Some(ProductPricePresentation {
   3556                     amount_minor_units: 650,
   3557                     currency_code: "USD".to_owned(),
   3558                     unit_label: "bag".to_owned(),
   3559                 }),
   3560                 line_total_minor_units: Some(1300),
   3561             }],
   3562             economics: buyer_order_economics.clone(),
   3563             workflow: TradeWorkflowProjection::from_buyer_order_status(
   3564                 order_id,
   3565                 BuyerOrderStatus::Scheduled,
   3566             )
   3567             .with_economics(buyer_order_economics),
   3568             validation_receipts: Vec::new(),
   3569             order_note: Some("Leave by the cooler".to_owned()),
   3570             repeat_demand: None,
   3571         };
   3572 
   3573         assert!(!listings.is_empty());
   3574         assert!(!cart.is_empty());
   3575         assert!(order_review.can_place_order);
   3576         assert!(!orders.is_empty());
   3577         assert_eq!(listing.fulfillment_methods.len(), 1);
   3578         assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled);
   3579         assert_eq!(
   3580             order_detail.workflow.agreement,
   3581             TradeAgreementStatus::Confirmed
   3582         );
   3583     }
   3584 
   3585     #[test]
   3586     fn today_agenda_stays_on_the_compact_order_row_contract() {
   3587         let today = TodayAgendaProjection {
   3588             orders_needing_action: vec![OrderListRow {
   3589                 order_id: super::OrderId::new(),
   3590                 farm_id: FarmId::new(),
   3591                 fulfillment_window_id: Some(super::FulfillmentWindowId::new()),
   3592                 order_number: "R-1002".to_owned(),
   3593                 customer_display_name: "Morgan".to_owned(),
   3594                 status: OrderStatus::NeedsAction,
   3595             }],
   3596             ..TodayAgendaProjection::default()
   3597         };
   3598         let orders_row_id = super::OrderId::new();
   3599         let orders_row = OrdersListRow {
   3600             order_id: orders_row_id,
   3601             farm_id: FarmId::new(),
   3602             fulfillment_window_id: None,
   3603             order_number: "R-2002".to_owned(),
   3604             customer_display_name: "Robin".to_owned(),
   3605             fulfillment_window_label: None,
   3606             pickup_location_label: None,
   3607             status: OrderStatus::Completed,
   3608             workflow: TradeWorkflowProjection::from_order_status(
   3609                 orders_row_id,
   3610                 OrderStatus::Completed,
   3611             ),
   3612             primary_action: None,
   3613         };
   3614 
   3615         assert_eq!(today.orders_needing_action.len(), 1);
   3616         assert_eq!(
   3617             today.orders_needing_action[0].status,
   3618             OrderStatus::NeedsAction
   3619         );
   3620         assert_eq!(orders_row.primary_action, None);
   3621         assert_eq!(orders_row.status, OrderStatus::Completed);
   3622     }
   3623 
   3624     #[test]
   3625     fn today_summary_attention_state_is_explicit() {
   3626         let quiet = TodaySummary {
   3627             farm_id: FarmId::new(),
   3628             orders_needing_action: 0,
   3629             low_stock_products: 0,
   3630             draft_products: 0,
   3631             reminders_due_soon: 0,
   3632         };
   3633         let busy = TodaySummary {
   3634             farm_id: FarmId::new(),
   3635             orders_needing_action: 1,
   3636             low_stock_products: 0,
   3637             draft_products: 0,
   3638             reminders_due_soon: 0,
   3639         };
   3640 
   3641         assert!(!quiet.has_attention_items());
   3642         assert!(busy.has_attention_items());
   3643     }
   3644 
   3645     #[test]
   3646     fn reminder_and_repeat_demand_contracts_are_explicit() {
   3647         let farm_id = FarmId::new();
   3648         let order_id = OrderId::new();
   3649         let fulfillment_window_id = FulfillmentWindowId::new();
   3650         let reminder = ReminderDeadlineProjection {
   3651             reminder_id: ReminderId::new(),
   3652             farm_id,
   3653             order_id: Some(order_id),
   3654             fulfillment_window_id: Some(fulfillment_window_id),
   3655             kind: ReminderKind::FulfillmentWindow,
   3656             surface: ReminderSurface::Today,
   3657             urgency: ReminderUrgency::DueSoon,
   3658             title: "Pickup closes soon".to_owned(),
   3659             detail: "Pack before the pickup window opens.".to_owned(),
   3660             deadline_at: "2026-04-24T15:00:00Z".to_owned(),
   3661             action_label: Some("Open pack day".to_owned()),
   3662             delivery_state: ReminderDeliveryState::Scheduled,
   3663         };
   3664         let repeat_demand = RepeatDemandHandoffProjection {
   3665             order_id,
   3666             farm_id,
   3667             eligibility: RepeatDemandEligibility::Partial,
   3668             available_item_count: 2,
   3669             unavailable_item_count: 1,
   3670         };
   3671 
   3672         let reminder_feed = ReminderFeedProjection {
   3673             items: vec![reminder.clone()],
   3674         };
   3675         let reminder_log = ReminderLogProjection {
   3676             entries: vec![ReminderLogEntryProjection {
   3677                 reminder_id: reminder.reminder_id,
   3678                 kind: reminder.kind,
   3679                 title: reminder.title.clone(),
   3680                 recorded_at: "2026-04-24T14:00:00Z".to_owned(),
   3681                 delivery_state: ReminderDeliveryState::Presented,
   3682                 detail: Some(reminder.detail.clone()),
   3683             }],
   3684         };
   3685 
   3686         assert_eq!(ReminderSurface::PackDay.storage_key(), "pack_day");
   3687         assert_eq!(ReminderUrgency::DueSoon.storage_key(), "due_soon");
   3688         assert_eq!(
   3689             ReminderDeliveryState::Acknowledged.storage_key(),
   3690             "acknowledged"
   3691         );
   3692         assert_eq!(
   3693             RepeatDemandEligibility::Unavailable.storage_key(),
   3694             "unavailable"
   3695         );
   3696         assert_eq!(reminder_feed.due_soon_count(), 1);
   3697         assert!(!reminder_log.is_empty());
   3698         assert_eq!(repeat_demand.unavailable_item_count, 1);
   3699     }
   3700 
   3701     #[test]
   3702     fn today_agenda_projection_tracks_attention_and_setup_independently() {
   3703         let calm = TodayAgendaProjection::default();
   3704         let with_attention = TodayAgendaProjection {
   3705             draft_products: vec![ProductListRow {
   3706                 product_id: super::ProductId::new(),
   3707                 farm_id: FarmId::new(),
   3708                 title: "Spring onions".to_owned(),
   3709                 status: super::ProductStatus::Draft,
   3710                 stock_count: 0,
   3711             }],
   3712             ..TodayAgendaProjection::default()
   3713         };
   3714         let with_setup = TodayAgendaProjection {
   3715             setup_checklist: vec![TodaySetupTask {
   3716                 kind: TodaySetupTaskKind::AddFulfillmentWindow,
   3717                 is_complete: false,
   3718             }],
   3719             ..TodayAgendaProjection::default()
   3720         };
   3721 
   3722         assert!(!calm.has_attention_items());
   3723         assert!(!calm.needs_setup());
   3724         assert!(with_attention.has_attention_items());
   3725         assert!(!with_attention.needs_setup());
   3726         assert!(!with_setup.has_attention_items());
   3727         assert!(with_setup.needs_setup());
   3728     }
   3729 
   3730     #[test]
   3731     fn today_agenda_projection_can_hold_truthful_lists() {
   3732         let projection = TodayAgendaProjection {
   3733             orders_needing_action: vec![OrderListRow {
   3734                 order_id: super::OrderId::new(),
   3735                 farm_id: FarmId::new(),
   3736                 fulfillment_window_id: Some(super::FulfillmentWindowId::new()),
   3737                 order_number: "R-1001".to_owned(),
   3738                 customer_display_name: "Casey".to_owned(),
   3739                 status: super::OrderStatus::NeedsAction,
   3740             }],
   3741             low_stock_products: vec![ProductListRow {
   3742                 product_id: super::ProductId::new(),
   3743                 farm_id: FarmId::new(),
   3744                 title: "Carrots".to_owned(),
   3745                 status: super::ProductStatus::Published,
   3746                 stock_count: 2,
   3747             }],
   3748             ..TodayAgendaProjection::default()
   3749         };
   3750 
   3751         assert_eq!(projection.orders_needing_action.len(), 1);
   3752         assert_eq!(projection.low_stock_products[0].stock_count, 2);
   3753         assert!(projection.has_attention_items());
   3754     }
   3755 
   3756     #[test]
   3757     fn farm_setup_section_order_is_frozen() {
   3758         assert_eq!(
   3759             FarmSetupSection::ordered(),
   3760             [
   3761                 FarmSetupSection::Farm,
   3762                 FarmSetupSection::Location,
   3763                 FarmSetupSection::OrderMethods,
   3764             ]
   3765         );
   3766     }
   3767 
   3768     #[test]
   3769     fn empty_farm_setup_draft_is_not_started_with_all_blockers() {
   3770         let draft = FarmSetupDraft::default();
   3771 
   3772         assert!(draft.is_empty());
   3773         assert_eq!(draft.readiness(), FarmSetupReadiness::NotStarted);
   3774         assert_eq!(
   3775             draft.blockers(),
   3776             vec![
   3777                 FarmSetupBlocker::AddFarmName,
   3778                 FarmSetupBlocker::AddLocationOrServiceArea,
   3779                 FarmSetupBlocker::ChooseOrderMethod,
   3780             ]
   3781         );
   3782     }
   3783 
   3784     #[test]
   3785     fn partial_farm_setup_draft_is_in_progress() {
   3786         let draft = FarmSetupDraft::new("North field farm", "", [FarmOrderMethod::Pickup]);
   3787 
   3788         assert_eq!(draft.readiness(), FarmSetupReadiness::InProgress);
   3789         assert_eq!(
   3790             draft.blockers(),
   3791             vec![FarmSetupBlocker::AddLocationOrServiceArea]
   3792         );
   3793     }
   3794 
   3795     #[test]
   3796     fn complete_farm_setup_draft_is_ready_and_deduplicates_order_methods() {
   3797         let draft = FarmSetupDraft::new(
   3798             "North field farm",
   3799             "Asheville, NC",
   3800             [
   3801                 FarmOrderMethod::Shipping,
   3802                 FarmOrderMethod::Pickup,
   3803                 FarmOrderMethod::Shipping,
   3804             ],
   3805         );
   3806 
   3807         assert_eq!(draft.readiness(), FarmSetupReadiness::Ready);
   3808         assert_eq!(draft.blockers(), Vec::<FarmSetupBlocker>::new());
   3809         assert_eq!(
   3810             draft.order_methods,
   3811             BTreeSet::from([FarmOrderMethod::Pickup, FarmOrderMethod::Shipping])
   3812         );
   3813     }
   3814 
   3815     #[test]
   3816     fn saved_farm_projection_is_always_ready() {
   3817         let saved_farm = super::FarmSummary {
   3818             farm_id: FarmId::new(),
   3819             display_name: "North field farm".to_owned(),
   3820             readiness: super::FarmReadiness::Ready,
   3821         };
   3822         let projection = FarmSetupProjection::from_saved_farm(saved_farm.clone());
   3823 
   3824         assert_eq!(projection.saved_farm, Some(saved_farm));
   3825         assert_eq!(projection.readiness, FarmSetupReadiness::Ready);
   3826         assert!(projection.blockers.is_empty());
   3827         assert!(projection.has_saved_farm());
   3828     }
   3829 
   3830     #[test]
   3831     fn farm_rules_projection_defaults_to_missing_v1_requirements() {
   3832         let projection = FarmRulesProjection::default();
   3833 
   3834         assert!(projection.farm_profile.is_none());
   3835         assert!(projection.pickup_locations.is_empty());
   3836         assert!(projection.operating_rules.is_none());
   3837         assert!(projection.fulfillment_windows.is_empty());
   3838         assert!(projection.blackout_periods.is_empty());
   3839         assert_eq!(
   3840             projection.readiness,
   3841             FarmRulesReadiness::missing_v1_basics()
   3842         );
   3843         assert!(!projection.is_ready());
   3844     }
   3845 
   3846     #[test]
   3847     fn farm_rules_readiness_and_timing_conflicts_are_explicit() {
   3848         let readiness = FarmRulesReadiness {
   3849             blockers: vec![FarmReadinessBlocker::MissingOperatingRules],
   3850             timing_conflicts: vec![FarmTimingConflict {
   3851                 kind: FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow,
   3852                 fulfillment_window_id: Some(super::FulfillmentWindowId::new()),
   3853                 blackout_period_id: Some(BlackoutPeriodId::new()),
   3854             }],
   3855         };
   3856 
   3857         assert_eq!(
   3858             FarmReadinessBlocker::MissingProfileBasics.storage_key(),
   3859             "missing_profile_basics"
   3860         );
   3861         assert_eq!(
   3862             FarmReadinessBlocker::MissingPickupLocation.storage_key(),
   3863             "missing_pickup_location"
   3864         );
   3865         assert_eq!(
   3866             FarmReadinessBlocker::MissingFulfillmentWindow.storage_key(),
   3867             "missing_fulfillment_window"
   3868         );
   3869         assert_eq!(
   3870             FarmReadinessBlocker::MissingOperatingRules.storage_key(),
   3871             "missing_operating_rules"
   3872         );
   3873         assert_eq!(
   3874             FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart.storage_key(),
   3875             "fulfillment_window_ends_before_start"
   3876         );
   3877         assert_eq!(
   3878             FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart.storage_key(),
   3879             "fulfillment_window_cutoff_after_start"
   3880         );
   3881         assert_eq!(
   3882             FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart.storage_key(),
   3883             "blackout_period_ends_before_start"
   3884         );
   3885         assert_eq!(
   3886             FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow.storage_key(),
   3887             "blackout_overlaps_fulfillment_window"
   3888         );
   3889         assert!(!readiness.is_ready());
   3890         assert!(FarmRulesReadiness::ready().is_ready());
   3891     }
   3892 
   3893     #[test]
   3894     fn farm_rules_projection_represents_full_v1_inventory() {
   3895         let farm_id = FarmId::new();
   3896         let pickup_location_id = PickupLocationId::new();
   3897         let fulfillment_window_id = super::FulfillmentWindowId::new();
   3898         let blackout_period_id = BlackoutPeriodId::new();
   3899         let projection = super::FarmRulesProjection {
   3900             farm_profile: Some(super::FarmProfileRecord {
   3901                 farm_id,
   3902                 display_name: "North field farm".to_owned(),
   3903                 timezone: "UTC".to_owned(),
   3904                 currency_code: "USD".to_owned(),
   3905             }),
   3906             pickup_locations: vec![super::PickupLocationRecord {
   3907                 pickup_location_id,
   3908                 farm_id,
   3909                 label: "Barn pickup".to_owned(),
   3910                 address_line: "14 Orchard Lane".to_owned(),
   3911                 directions: Some("Drive to the red barn.".to_owned()),
   3912                 is_default: true,
   3913             }],
   3914             operating_rules: Some(super::FarmOperatingRulesRecord {
   3915                 farm_id,
   3916                 promise_lead_hours: 24,
   3917                 substitution_policy: "ask_customer".to_owned(),
   3918             }),
   3919             fulfillment_windows: vec![super::FulfillmentWindowRecord {
   3920                 fulfillment_window_id,
   3921                 farm_id,
   3922                 pickup_location_id,
   3923                 label: "Friday pickup".to_owned(),
   3924                 starts_at: "2026-04-25T14:00:00Z".to_owned(),
   3925                 ends_at: "2026-04-25T18:00:00Z".to_owned(),
   3926                 order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(),
   3927             }],
   3928             blackout_periods: vec![super::BlackoutPeriodRecord {
   3929                 blackout_period_id,
   3930                 farm_id,
   3931                 label: "Spring break".to_owned(),
   3932                 starts_at: "2026-05-01T00:00:00Z".to_owned(),
   3933                 ends_at: "2026-05-03T23:59:59Z".to_owned(),
   3934             }],
   3935             readiness: FarmRulesReadiness::ready(),
   3936         };
   3937         let saved_farm = super::FarmSummary {
   3938             farm_id,
   3939             display_name: "North field farm".to_owned(),
   3940             readiness: super::FarmReadiness::Ready,
   3941         };
   3942 
   3943         assert!(projection.is_ready());
   3944         assert_eq!(
   3945             projection
   3946                 .farm_profile
   3947                 .as_ref()
   3948                 .map(|profile| profile.display_name.as_str()),
   3949             Some(saved_farm.display_name.as_str())
   3950         );
   3951         assert_eq!(
   3952             projection.pickup_locations[0].pickup_location_id,
   3953             pickup_location_id
   3954         );
   3955         assert_eq!(
   3956             projection.fulfillment_windows[0].pickup_location_id,
   3957             pickup_location_id
   3958         );
   3959         assert_eq!(
   3960             projection.blackout_periods[0].blackout_period_id,
   3961             blackout_period_id
   3962         );
   3963         assert_eq!(saved_farm.readiness, super::FarmReadiness::Ready);
   3964     }
   3965 
   3966     #[test]
   3967     fn settings_preference_storage_keys_are_stable() {
   3968         assert_eq!(
   3969             SettingsPreference::AllowRelayConnections.storage_key(),
   3970             "allow_relay_connections"
   3971         );
   3972         assert_eq!(
   3973             SettingsPreference::UseMediaServers.storage_key(),
   3974             "use_media_servers"
   3975         );
   3976         assert_eq!(SettingsPreference::UseNip05.storage_key(), "use_nip05");
   3977         assert_eq!(
   3978             SettingsPreference::LaunchAtLogin.storage_key(),
   3979             "launch_at_login"
   3980         );
   3981     }
   3982 
   3983     #[test]
   3984     fn activity_kind_storage_keys_are_stable() {
   3985         assert_eq!(AppActivityKind::HomeOpened.storage_key(), "home_opened");
   3986         assert_eq!(
   3987             AppActivityKind::SettingsOpened {
   3988                 section: SettingsSection::About,
   3989             }
   3990             .storage_key(),
   3991             "settings_opened"
   3992         );
   3993         assert_eq!(
   3994             AppActivityKind::SettingsSectionSelected {
   3995                 section: SettingsSection::Settings,
   3996             }
   3997             .storage_key(),
   3998             "settings_section_selected"
   3999         );
   4000         assert_eq!(
   4001             AppActivityKind::SettingsPreferenceUpdated {
   4002                 preference: SettingsPreference::LaunchAtLogin,
   4003                 enabled: true,
   4004             }
   4005             .storage_key(),
   4006             "settings_preference_updated"
   4007         );
   4008     }
   4009 
   4010     #[test]
   4011     fn activity_context_preserves_recent_event_order() {
   4012         let first = AppActivityEvent {
   4013             activity_event_id: ActivityEventId::new(),
   4014             recorded_at: "2026-04-18T00:00:00.000Z".to_owned(),
   4015             kind: AppActivityKind::HomeOpened,
   4016         };
   4017         let second = AppActivityEvent {
   4018             activity_event_id: ActivityEventId::new(),
   4019             recorded_at: "2026-04-18T00:01:00.000Z".to_owned(),
   4020             kind: AppActivityKind::SettingsOpened {
   4021                 section: SettingsSection::About,
   4022             },
   4023         };
   4024         let context = AppActivityContext::from_recent_events(vec![second.clone(), first.clone()]);
   4025 
   4026         assert_eq!(context.recent_events, vec![second, first]);
   4027     }
   4028 }