app

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

publish.rs (45251B)


      1 use radroots_app_view::{
      2     FarmId, FarmReadiness, FulfillmentWindowId, OrderId, ProductId, ProductStatus,
      3 };
      4 use radroots_sdk::protocol::order::{
      5     RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRevisionOutcome,
      6 };
      7 use radroots_sdk::{
      8     FARM_PUBLISH_OPERATION_KIND, LISTING_PUBLISH_OPERATION_KIND, ORDER_CANCELLATION_OPERATION_KIND,
      9     ORDER_DECISION_OPERATION_KIND, ORDER_REVISION_DECISION_OPERATION_KIND,
     10     ORDER_REVISION_PROPOSAL_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND,
     11 };
     12 use serde::{Deserialize, Serialize};
     13 use thiserror::Error;
     14 
     15 use crate::{PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind};
     16 
     17 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
     18 #[serde(rename_all = "snake_case")]
     19 pub enum AppPublishWorkKind {
     20     FarmProfile,
     21     Listing,
     22     OrderRequest,
     23     OrderDecision,
     24     OrderRevisionProposal,
     25     OrderRevisionDecision,
     26     OrderCancellation,
     27 }
     28 
     29 impl AppPublishWorkKind {
     30     pub const fn storage_key(self) -> &'static str {
     31         match self {
     32             Self::FarmProfile => "farm_profile",
     33             Self::Listing => "listing",
     34             Self::OrderRequest => "order_request",
     35             Self::OrderDecision => "order_decision",
     36             Self::OrderRevisionProposal => "order_revision_proposal",
     37             Self::OrderRevisionDecision => "order_revision_decision",
     38             Self::OrderCancellation => "order_cancellation",
     39         }
     40     }
     41 
     42     pub const fn sdk_operation(self) -> &'static str {
     43         match self {
     44             Self::FarmProfile => FARM_PUBLISH_OPERATION_KIND,
     45             Self::Listing => LISTING_PUBLISH_OPERATION_KIND,
     46             Self::OrderRequest => ORDER_SUBMIT_OPERATION_KIND,
     47             Self::OrderDecision => ORDER_DECISION_OPERATION_KIND,
     48             Self::OrderRevisionProposal => ORDER_REVISION_PROPOSAL_OPERATION_KIND,
     49             Self::OrderRevisionDecision => ORDER_REVISION_DECISION_OPERATION_KIND,
     50             Self::OrderCancellation => ORDER_CANCELLATION_OPERATION_KIND,
     51         }
     52     }
     53 }
     54 
     55 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
     56 pub struct AppPublishContext {
     57     pub account_id: String,
     58     pub source: String,
     59     pub source_local_event_id: Option<String>,
     60 }
     61 
     62 impl AppPublishContext {
     63     pub fn new(account_id: impl Into<String>, source: impl Into<String>) -> Self {
     64         Self {
     65             account_id: account_id.into(),
     66             source: source.into(),
     67             source_local_event_id: None,
     68         }
     69     }
     70 
     71     pub fn with_source_local_event_id(mut self, source_local_event_id: impl Into<String>) -> Self {
     72         self.source_local_event_id = Some(source_local_event_id.into());
     73         self
     74     }
     75 
     76     fn validation_failures(&self, failures: &mut Vec<AppPublishValidationFailure>) {
     77         if self.account_id.trim().is_empty() {
     78             failures.push(AppPublishValidationFailure::MissingAccountId);
     79         }
     80 
     81         if self.source.trim().is_empty() {
     82             failures.push(AppPublishValidationFailure::MissingSource);
     83         }
     84     }
     85 }
     86 
     87 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
     88 pub struct AppFarmProfilePublishPayload {
     89     pub context: AppPublishContext,
     90     pub farm_id: FarmId,
     91     pub display_name: String,
     92     pub readiness: Option<FarmReadiness>,
     93 }
     94 
     95 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
     96 pub struct AppListingPublishPayload {
     97     pub context: AppPublishContext,
     98     pub product_id: ProductId,
     99     pub listing_d_tag: Option<String>,
    100     pub farm_id: Option<FarmId>,
    101     pub farm_pubkey: Option<String>,
    102     pub farm_d_tag: Option<String>,
    103     pub title: String,
    104     pub subtitle: Option<String>,
    105     pub category: Option<String>,
    106     pub unit_label: String,
    107     pub price_minor_units: Option<u32>,
    108     pub price_currency: String,
    109     pub stock_quantity: Option<u32>,
    110     pub availability_window_id: Option<FulfillmentWindowId>,
    111     pub availability_starts_at: Option<String>,
    112     pub availability_ends_at: Option<String>,
    113     pub fulfillment_method: Option<String>,
    114     pub fulfillment_location: Option<String>,
    115     pub status: ProductStatus,
    116 }
    117 
    118 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    119 pub struct AppOrderRequestItemPayload {
    120     pub product_id: ProductId,
    121     pub quantity: u32,
    122 }
    123 
    124 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    125 pub struct AppOrderRequestPublishPayload {
    126     pub context: AppPublishContext,
    127     pub order_id: OrderId,
    128     pub farm_id: FarmId,
    129     pub status: Option<String>,
    130     pub order_document_json: Option<serde_json::Value>,
    131     pub listing_addr: Option<String>,
    132     pub listing_event_id: Option<String>,
    133     pub listing_relays: Vec<String>,
    134     pub buyer_pubkey: Option<String>,
    135     pub seller_pubkey: Option<String>,
    136     pub items: Vec<AppOrderRequestItemPayload>,
    137     pub currency_code: Option<String>,
    138     pub total_minor_units: Option<u32>,
    139     pub note: Option<String>,
    140 }
    141 
    142 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    143 pub struct AppOrderDecisionInventoryCommitment {
    144     pub bin_id: String,
    145     pub bin_count: u32,
    146 }
    147 
    148 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    149 #[serde(rename_all = "snake_case", tag = "decision")]
    150 pub enum AppOrderDecisionPayload {
    151     Accepted {
    152         inventory_commitments: Vec<AppOrderDecisionInventoryCommitment>,
    153     },
    154     Declined {
    155         reason: String,
    156     },
    157 }
    158 
    159 impl AppOrderDecisionPayload {
    160     pub const fn storage_key(&self) -> &'static str {
    161         match self {
    162             Self::Accepted { .. } => "accepted",
    163             Self::Declined { .. } => "declined",
    164         }
    165     }
    166 }
    167 
    168 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    169 pub struct AppOrderDecisionPublishPayload {
    170     pub context: AppPublishContext,
    171     pub app_order_id: OrderId,
    172     pub farm_id: FarmId,
    173     pub trade_order_id: String,
    174     pub request_event_id: String,
    175     pub listing_event_id: Option<String>,
    176     pub listing_addr: String,
    177     pub buyer_pubkey: String,
    178     pub seller_pubkey: String,
    179     pub decision: AppOrderDecisionPayload,
    180 }
    181 
    182 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    183 pub struct AppOrderRevisionProposalPublishPayload {
    184     pub context: AppPublishContext,
    185     pub app_order_id: OrderId,
    186     pub farm_id: FarmId,
    187     pub trade_order_id: String,
    188     pub request_event_id: String,
    189     pub prev_event_id: String,
    190     pub revision_id: String,
    191     pub listing_addr: String,
    192     pub buyer_pubkey: String,
    193     pub seller_pubkey: String,
    194     pub items: Vec<RadrootsOrderItem>,
    195     pub economics: RadrootsOrderEconomics,
    196     pub reason: String,
    197 }
    198 
    199 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    200 pub struct AppOrderRevisionDecisionPublishPayload {
    201     pub context: AppPublishContext,
    202     pub app_order_id: OrderId,
    203     pub farm_id: FarmId,
    204     pub trade_order_id: String,
    205     pub request_event_id: String,
    206     pub prev_event_id: String,
    207     pub revision_id: String,
    208     pub listing_addr: String,
    209     pub buyer_pubkey: String,
    210     pub seller_pubkey: String,
    211     pub decision: RadrootsOrderRevisionOutcome,
    212 }
    213 
    214 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    215 pub struct AppOrderCancellationPublishPayload {
    216     pub context: AppPublishContext,
    217     pub app_order_id: OrderId,
    218     pub farm_id: FarmId,
    219     pub trade_order_id: String,
    220     pub request_event_id: String,
    221     pub prev_event_id: String,
    222     pub listing_addr: String,
    223     pub buyer_pubkey: String,
    224     pub seller_pubkey: String,
    225     pub reason: String,
    226 }
    227 
    228 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    229 #[serde(tag = "publish_kind", content = "payload", rename_all = "snake_case")]
    230 pub enum AppPublishPayload {
    231     FarmProfile(AppFarmProfilePublishPayload),
    232     Listing(AppListingPublishPayload),
    233     OrderRequest(AppOrderRequestPublishPayload),
    234     OrderDecision(AppOrderDecisionPublishPayload),
    235     OrderRevisionProposal(AppOrderRevisionProposalPublishPayload),
    236     OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload),
    237     OrderCancellation(AppOrderCancellationPublishPayload),
    238 }
    239 
    240 impl AppPublishPayload {
    241     pub const fn work_kind(&self) -> AppPublishWorkKind {
    242         match self {
    243             Self::FarmProfile(_) => AppPublishWorkKind::FarmProfile,
    244             Self::Listing(_) => AppPublishWorkKind::Listing,
    245             Self::OrderRequest(_) => AppPublishWorkKind::OrderRequest,
    246             Self::OrderDecision(_) => AppPublishWorkKind::OrderDecision,
    247             Self::OrderRevisionProposal(_) => AppPublishWorkKind::OrderRevisionProposal,
    248             Self::OrderRevisionDecision(_) => AppPublishWorkKind::OrderRevisionDecision,
    249             Self::OrderCancellation(_) => AppPublishWorkKind::OrderCancellation,
    250         }
    251     }
    252 
    253     pub const fn operation_kind(&self) -> SyncOperationKind {
    254         SyncOperationKind::Upsert
    255     }
    256 
    257     pub fn aggregate_ref(&self) -> SyncAggregateRef {
    258         match self {
    259             Self::FarmProfile(payload) => SyncAggregateRef::Farm(payload.farm_id),
    260             Self::Listing(payload) => SyncAggregateRef::Product(payload.product_id),
    261             Self::OrderRequest(payload) => SyncAggregateRef::Order(payload.order_id),
    262             Self::OrderDecision(payload) => SyncAggregateRef::Order(payload.app_order_id),
    263             Self::OrderRevisionProposal(payload) => SyncAggregateRef::Order(payload.app_order_id),
    264             Self::OrderRevisionDecision(payload) => SyncAggregateRef::Order(payload.app_order_id),
    265             Self::OrderCancellation(payload) => SyncAggregateRef::Order(payload.app_order_id),
    266         }
    267     }
    268 
    269     pub fn validation_failures(&self) -> Vec<AppPublishValidationFailure> {
    270         let mut failures = Vec::new();
    271 
    272         match self {
    273             Self::FarmProfile(payload) => {
    274                 payload.context.validation_failures(&mut failures);
    275                 if payload.display_name.trim().is_empty() {
    276                     failures.push(AppPublishValidationFailure::MissingFarmDisplayName);
    277                 }
    278             }
    279             Self::Listing(payload) => {
    280                 payload.context.validation_failures(&mut failures);
    281                 if payload.farm_id.is_none() {
    282                     failures.push(AppPublishValidationFailure::MissingListingFarmId);
    283                 }
    284                 if payload
    285                     .farm_pubkey
    286                     .as_deref()
    287                     .is_none_or(|value| value.trim().is_empty())
    288                 {
    289                     failures.push(AppPublishValidationFailure::MissingListingFarmPubkey);
    290                 }
    291                 if payload
    292                     .category
    293                     .as_deref()
    294                     .is_none_or(|value| value.trim().is_empty())
    295                 {
    296                     failures.push(AppPublishValidationFailure::MissingListingCategory);
    297                 }
    298                 if payload.title.trim().is_empty() {
    299                     failures.push(AppPublishValidationFailure::MissingListingTitle);
    300                 }
    301                 if payload.unit_label.trim().is_empty() {
    302                     failures.push(AppPublishValidationFailure::MissingListingUnit);
    303                 }
    304                 if payload.price_minor_units.is_none_or(|value| value == 0) {
    305                     failures.push(AppPublishValidationFailure::MissingListingPrice);
    306                 }
    307                 if payload.price_currency.trim().is_empty() {
    308                     failures.push(AppPublishValidationFailure::MissingListingCurrency);
    309                 }
    310                 if payload.availability_window_id.is_none()
    311                     || payload
    312                         .availability_starts_at
    313                         .as_deref()
    314                         .is_none_or(|value| value.trim().is_empty())
    315                     || payload
    316                         .availability_ends_at
    317                         .as_deref()
    318                         .is_none_or(|value| value.trim().is_empty())
    319                 {
    320                     failures.push(AppPublishValidationFailure::MissingListingAvailability);
    321                 }
    322                 if payload.stock_quantity.is_none() {
    323                     failures.push(AppPublishValidationFailure::MissingListingStock);
    324                 }
    325                 if payload
    326                     .fulfillment_method
    327                     .as_deref()
    328                     .is_none_or(|value| value.trim().is_empty())
    329                 {
    330                     failures.push(AppPublishValidationFailure::MissingListingFulfillmentMethod);
    331                 }
    332                 if payload
    333                     .fulfillment_location
    334                     .as_deref()
    335                     .is_none_or(|value| value.trim().is_empty())
    336                 {
    337                     failures.push(AppPublishValidationFailure::MissingListingFulfillmentLocation);
    338                 }
    339             }
    340             Self::OrderRequest(payload) => {
    341                 payload.context.validation_failures(&mut failures);
    342                 if payload.order_document_json.is_none() {
    343                     failures.push(AppPublishValidationFailure::MissingOrderDocument);
    344                 }
    345                 if payload
    346                     .listing_addr
    347                     .as_deref()
    348                     .is_none_or(|value| value.trim().is_empty())
    349                 {
    350                     failures.push(AppPublishValidationFailure::MissingOrderListingAddress);
    351                 }
    352                 if payload
    353                     .listing_event_id
    354                     .as_deref()
    355                     .is_none_or(|value| value.trim().is_empty())
    356                 {
    357                     failures.push(AppPublishValidationFailure::MissingOrderListingEventId);
    358                 }
    359                 if payload
    360                     .listing_relays
    361                     .iter()
    362                     .all(|relay| relay.trim().is_empty())
    363                 {
    364                     failures.push(AppPublishValidationFailure::MissingOrderListingRelay);
    365                 }
    366                 if payload
    367                     .buyer_pubkey
    368                     .as_deref()
    369                     .is_none_or(|value| value.trim().is_empty())
    370                 {
    371                     failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey);
    372                 }
    373                 if payload
    374                     .seller_pubkey
    375                     .as_deref()
    376                     .is_none_or(|value| value.trim().is_empty())
    377                 {
    378                     failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey);
    379                 }
    380                 if payload.items.is_empty() || payload.items.iter().any(|item| item.quantity == 0) {
    381                     failures.push(AppPublishValidationFailure::MissingOrderItems);
    382                 }
    383                 if payload
    384                     .currency_code
    385                     .as_deref()
    386                     .is_none_or(|value| value.trim().is_empty())
    387                 {
    388                     failures.push(AppPublishValidationFailure::MissingOrderCurrency);
    389                 }
    390                 if payload.total_minor_units.is_none() {
    391                     failures.push(AppPublishValidationFailure::MissingOrderTotal);
    392                 }
    393             }
    394             Self::OrderDecision(payload) => {
    395                 payload.context.validation_failures(&mut failures);
    396                 if payload.trade_order_id.trim().is_empty() {
    397                     failures.push(AppPublishValidationFailure::MissingOrderTradeOrderId);
    398                 }
    399                 if payload.request_event_id.trim().is_empty() {
    400                     failures.push(AppPublishValidationFailure::MissingOrderRequestEventId);
    401                 }
    402                 if payload.listing_addr.trim().is_empty() {
    403                     failures.push(AppPublishValidationFailure::MissingOrderListingAddress);
    404                 }
    405                 if payload.buyer_pubkey.trim().is_empty() {
    406                     failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey);
    407                 }
    408                 if payload.seller_pubkey.trim().is_empty() {
    409                     failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey);
    410                 }
    411                 match &payload.decision {
    412                     AppOrderDecisionPayload::Accepted {
    413                         inventory_commitments,
    414                     } => {
    415                         if inventory_commitments.is_empty()
    416                             || inventory_commitments.iter().any(|commitment| {
    417                                 commitment.bin_id.trim().is_empty() || commitment.bin_count == 0
    418                             })
    419                         {
    420                             failures
    421                                 .push(AppPublishValidationFailure::MissingOrderDecisionInventory);
    422                         }
    423                     }
    424                     AppOrderDecisionPayload::Declined { reason } => {
    425                         if reason.trim().is_empty() {
    426                             failures.push(AppPublishValidationFailure::MissingOrderDeclineReason);
    427                         }
    428                     }
    429                 }
    430             }
    431             Self::OrderRevisionProposal(payload) => {
    432                 validate_lifecycle_order_fields(
    433                     &payload.context,
    434                     payload.trade_order_id.as_str(),
    435                     payload.request_event_id.as_str(),
    436                     payload.prev_event_id.as_str(),
    437                     payload.listing_addr.as_str(),
    438                     payload.buyer_pubkey.as_str(),
    439                     payload.seller_pubkey.as_str(),
    440                     &mut failures,
    441                 );
    442                 if payload.revision_id.trim().is_empty() {
    443                     failures.push(AppPublishValidationFailure::MissingOrderRevisionId);
    444                 }
    445                 if payload.items.is_empty()
    446                     || payload
    447                         .items
    448                         .iter()
    449                         .any(|item| item.bin_id.trim().is_empty() || item.bin_count == 0)
    450                 {
    451                     failures.push(AppPublishValidationFailure::MissingOrderRevisionItems);
    452                 }
    453                 if payload.economics.validate().is_err() {
    454                     failures.push(AppPublishValidationFailure::InvalidOrderRevisionEconomics);
    455                 }
    456                 if payload.reason.trim().is_empty() {
    457                     failures.push(AppPublishValidationFailure::MissingOrderRevisionReason);
    458                 }
    459             }
    460             Self::OrderRevisionDecision(payload) => {
    461                 validate_lifecycle_order_fields(
    462                     &payload.context,
    463                     payload.trade_order_id.as_str(),
    464                     payload.request_event_id.as_str(),
    465                     payload.prev_event_id.as_str(),
    466                     payload.listing_addr.as_str(),
    467                     payload.buyer_pubkey.as_str(),
    468                     payload.seller_pubkey.as_str(),
    469                     &mut failures,
    470                 );
    471                 if payload.revision_id.trim().is_empty() {
    472                     failures.push(AppPublishValidationFailure::MissingOrderRevisionId);
    473                 }
    474                 if payload.decision.validate().is_err() {
    475                     failures.push(AppPublishValidationFailure::MissingOrderRevisionDecisionReason);
    476                 }
    477             }
    478             Self::OrderCancellation(payload) => {
    479                 validate_lifecycle_order_fields(
    480                     &payload.context,
    481                     payload.trade_order_id.as_str(),
    482                     payload.request_event_id.as_str(),
    483                     payload.prev_event_id.as_str(),
    484                     payload.listing_addr.as_str(),
    485                     payload.buyer_pubkey.as_str(),
    486                     payload.seller_pubkey.as_str(),
    487                     &mut failures,
    488                 );
    489                 if payload.reason.trim().is_empty() {
    490                     failures.push(AppPublishValidationFailure::MissingOrderCancellationReason);
    491                 }
    492             }
    493         }
    494 
    495         failures
    496     }
    497 
    498     pub fn validate(&self) -> Result<(), AppPublishValidationFailureSet> {
    499         let reason_codes = self.validation_failures();
    500         if reason_codes.is_empty() {
    501             Ok(())
    502         } else {
    503             Err(AppPublishValidationFailureSet { reason_codes })
    504         }
    505     }
    506 
    507     pub fn to_payload_json(&self) -> Result<String, AppPublishPayloadJsonError> {
    508         serde_json::to_string(self).map_err(|source| AppPublishPayloadJsonError::Serialize {
    509             message: source.to_string(),
    510         })
    511     }
    512 }
    513 
    514 fn validate_lifecycle_order_fields(
    515     context: &AppPublishContext,
    516     trade_order_id: &str,
    517     request_event_id: &str,
    518     prev_event_id: &str,
    519     listing_addr: &str,
    520     buyer_pubkey: &str,
    521     seller_pubkey: &str,
    522     failures: &mut Vec<AppPublishValidationFailure>,
    523 ) {
    524     context.validation_failures(failures);
    525     if trade_order_id.trim().is_empty() {
    526         failures.push(AppPublishValidationFailure::MissingOrderTradeOrderId);
    527     }
    528     if request_event_id.trim().is_empty() {
    529         failures.push(AppPublishValidationFailure::MissingOrderRequestEventId);
    530     }
    531     if prev_event_id.trim().is_empty() {
    532         failures.push(AppPublishValidationFailure::MissingOrderPreviousEventId);
    533     }
    534     if listing_addr.trim().is_empty() {
    535         failures.push(AppPublishValidationFailure::MissingOrderListingAddress);
    536     }
    537     if buyer_pubkey.trim().is_empty() {
    538         failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey);
    539     }
    540     if seller_pubkey.trim().is_empty() {
    541         failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey);
    542     }
    543 }
    544 
    545 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
    546 #[serde(rename_all = "snake_case")]
    547 pub enum AppPublishValidationFailure {
    548     MissingAccountId,
    549     MissingSource,
    550     MissingFarmDisplayName,
    551     MissingListingFarmId,
    552     MissingListingFarmPubkey,
    553     MissingListingCategory,
    554     MissingListingTitle,
    555     MissingListingUnit,
    556     MissingListingPrice,
    557     MissingListingCurrency,
    558     MissingListingAvailability,
    559     MissingListingStock,
    560     MissingListingFulfillmentMethod,
    561     MissingListingFulfillmentLocation,
    562     MissingOrderDocument,
    563     MissingOrderListingAddress,
    564     MissingOrderListingEventId,
    565     MissingOrderListingRelay,
    566     MissingOrderBuyerPubkey,
    567     MissingOrderSellerPubkey,
    568     MissingOrderItems,
    569     MissingOrderCurrency,
    570     MissingOrderTotal,
    571     MissingOrderTradeOrderId,
    572     MissingOrderRequestEventId,
    573     MissingOrderPreviousEventId,
    574     MissingOrderDecisionInventory,
    575     MissingOrderDeclineReason,
    576     MissingOrderRevisionId,
    577     MissingOrderRevisionItems,
    578     InvalidOrderRevisionEconomics,
    579     MissingOrderRevisionReason,
    580     MissingOrderRevisionDecisionReason,
    581     MissingOrderCancellationReason,
    582 }
    583 
    584 impl AppPublishValidationFailure {
    585     pub const fn storage_key(self) -> &'static str {
    586         match self {
    587             Self::MissingAccountId => "missing_account_id",
    588             Self::MissingSource => "missing_source",
    589             Self::MissingFarmDisplayName => "missing_farm_display_name",
    590             Self::MissingListingFarmId => "missing_listing_farm_id",
    591             Self::MissingListingFarmPubkey => "missing_listing_farm_pubkey",
    592             Self::MissingListingCategory => "missing_listing_category",
    593             Self::MissingListingTitle => "missing_listing_title",
    594             Self::MissingListingUnit => "missing_listing_unit",
    595             Self::MissingListingPrice => "missing_listing_price",
    596             Self::MissingListingCurrency => "missing_listing_currency",
    597             Self::MissingListingAvailability => "missing_listing_availability",
    598             Self::MissingListingStock => "missing_listing_stock",
    599             Self::MissingListingFulfillmentMethod => "missing_listing_fulfillment_method",
    600             Self::MissingListingFulfillmentLocation => "missing_listing_fulfillment_location",
    601             Self::MissingOrderDocument => "missing_order_document",
    602             Self::MissingOrderListingAddress => "missing_order_listing_address",
    603             Self::MissingOrderListingEventId => "missing_order_listing_event_id",
    604             Self::MissingOrderListingRelay => "missing_order_listing_relay",
    605             Self::MissingOrderBuyerPubkey => "missing_order_buyer_pubkey",
    606             Self::MissingOrderSellerPubkey => "missing_order_seller_pubkey",
    607             Self::MissingOrderItems => "missing_order_items",
    608             Self::MissingOrderCurrency => "missing_order_currency",
    609             Self::MissingOrderTotal => "missing_order_total",
    610             Self::MissingOrderTradeOrderId => "missing_order_trade_order_id",
    611             Self::MissingOrderRequestEventId => "missing_order_request_event_id",
    612             Self::MissingOrderPreviousEventId => "missing_order_previous_event_id",
    613             Self::MissingOrderDecisionInventory => "missing_order_decision_inventory",
    614             Self::MissingOrderDeclineReason => "missing_order_decline_reason",
    615             Self::MissingOrderRevisionId => "missing_order_revision_id",
    616             Self::MissingOrderRevisionItems => "missing_order_revision_items",
    617             Self::InvalidOrderRevisionEconomics => "invalid_order_revision_economics",
    618             Self::MissingOrderRevisionReason => "missing_order_revision_reason",
    619             Self::MissingOrderRevisionDecisionReason => "missing_order_revision_decision_reason",
    620             Self::MissingOrderCancellationReason => "missing_order_cancellation_reason",
    621         }
    622     }
    623 }
    624 
    625 #[derive(Clone, Debug, Eq, Error, PartialEq)]
    626 #[error("app publish payload is invalid: {reason_codes:?}")]
    627 pub struct AppPublishValidationFailureSet {
    628     pub reason_codes: Vec<AppPublishValidationFailure>,
    629 }
    630 
    631 #[derive(Clone, Debug, Eq, Error, PartialEq)]
    632 pub enum AppPublishPayloadJsonError {
    633     #[error("app publish payload serialization failed: {message}")]
    634     Serialize { message: String },
    635     #[error("app publish payload json is invalid: {message}")]
    636     Deserialize { message: String },
    637 }
    638 
    639 impl PendingSyncOperation {
    640     pub fn from_publish_payload(
    641         payload: AppPublishPayload,
    642         created_at: impl Into<String>,
    643     ) -> Result<Self, AppPublishPayloadJsonError> {
    644         let created_at = created_at.into();
    645         let aggregate = payload.aggregate_ref();
    646         let operation = payload.operation_kind();
    647         Ok(Self {
    648             operation_key: PendingSyncOperation::deterministic_operation_key(&aggregate, operation),
    649             aggregate,
    650             operation,
    651             payload_json: payload.to_payload_json()?,
    652             created_at: created_at.clone(),
    653             available_at: created_at,
    654             attempt_count: 0,
    655             state: PendingSyncOperationState::Pending,
    656             last_error_message: None,
    657         })
    658     }
    659 
    660     pub fn publish_payload(&self) -> Result<AppPublishPayload, AppPublishPayloadJsonError> {
    661         serde_json::from_str(self.payload_json.as_str()).map_err(|source| {
    662             AppPublishPayloadJsonError::Deserialize {
    663                 message: source.to_string(),
    664             }
    665         })
    666     }
    667 }
    668 
    669 #[cfg(test)]
    670 mod tests {
    671     use super::{
    672         AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload,
    673         AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderRequestItemPayload,
    674         AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload,
    675         AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload,
    676         AppPublishValidationFailure, AppPublishWorkKind, FARM_PUBLISH_OPERATION_KIND,
    677         ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND,
    678         ORDER_REVISION_DECISION_OPERATION_KIND, ORDER_REVISION_PROPOSAL_OPERATION_KIND,
    679     };
    680     use crate::{
    681         PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind,
    682     };
    683     use radroots_app_view::{FarmId, FarmReadiness, OrderId, ProductId, ProductStatus};
    684     use radroots_sdk::protocol::order::{
    685         RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRevisionOutcome,
    686     };
    687     use serde_json::json;
    688 
    689     #[test]
    690     fn publish_payload_serializes_with_stable_kind_and_sdk_target() {
    691         let farm_id = FarmId::new();
    692         let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload {
    693             context: AppPublishContext::new("acct_local", "farm_setup")
    694                 .with_source_local_event_id("local-event-1"),
    695             farm_id,
    696             display_name: "North Farm".to_owned(),
    697             readiness: Some(FarmReadiness::Ready),
    698         });
    699 
    700         assert_eq!(payload.work_kind().storage_key(), "farm_profile");
    701         assert_eq!(
    702             payload.work_kind().sdk_operation(),
    703             FARM_PUBLISH_OPERATION_KIND
    704         );
    705         assert_eq!(payload.validation_failures(), Vec::new());
    706 
    707         let operation =
    708             PendingSyncOperation::from_publish_payload(payload.clone(), "2026-04-20T18:00:00Z")
    709                 .expect("typed publish payload should serialize");
    710 
    711         assert_eq!(operation.aggregate, SyncAggregateRef::Farm(farm_id));
    712         assert_eq!(operation.operation_key, format!("farm:{farm_id}:upsert"));
    713         assert_eq!(operation.operation, SyncOperationKind::Upsert);
    714         assert_eq!(operation.state, PendingSyncOperationState::Pending);
    715         assert_eq!(operation.last_error_message, None);
    716         assert_eq!(operation.created_at, operation.available_at);
    717         assert_eq!(
    718             operation.publish_payload().expect("payload should parse"),
    719             payload
    720         );
    721     }
    722 
    723     #[test]
    724     fn publish_work_kinds_are_current_agreement_surface() {
    725         let work_kinds = [
    726             AppPublishWorkKind::FarmProfile,
    727             AppPublishWorkKind::Listing,
    728             AppPublishWorkKind::OrderRequest,
    729             AppPublishWorkKind::OrderDecision,
    730             AppPublishWorkKind::OrderRevisionProposal,
    731             AppPublishWorkKind::OrderRevisionDecision,
    732             AppPublishWorkKind::OrderCancellation,
    733         ];
    734 
    735         assert_eq!(work_kinds.len(), 7);
    736         assert_eq!(work_kinds[0].storage_key(), "farm_profile");
    737         assert_eq!(work_kinds[1].storage_key(), "listing");
    738         assert_eq!(work_kinds[2].storage_key(), "order_request");
    739         assert_eq!(work_kinds[3].storage_key(), "order_decision");
    740         assert_eq!(work_kinds[4].storage_key(), "order_revision_proposal");
    741         assert_eq!(work_kinds[5].storage_key(), "order_revision_decision");
    742         assert_eq!(work_kinds[6].storage_key(), "order_cancellation");
    743     }
    744 
    745     #[test]
    746     fn listing_publish_payload_reports_stable_validation_reason_codes() {
    747         let payload = AppPublishPayload::Listing(AppListingPublishPayload {
    748             context: AppPublishContext::new("", ""),
    749             product_id: ProductId::new(),
    750             listing_d_tag: None,
    751             farm_id: None,
    752             farm_pubkey: None,
    753             farm_d_tag: None,
    754             title: " ".to_owned(),
    755             subtitle: None,
    756             category: None,
    757             unit_label: String::new(),
    758             price_minor_units: Some(0),
    759             price_currency: String::new(),
    760             stock_quantity: Some(4),
    761             availability_window_id: None,
    762             availability_starts_at: None,
    763             availability_ends_at: None,
    764             fulfillment_method: None,
    765             fulfillment_location: None,
    766             status: ProductStatus::Published,
    767         });
    768 
    769         let reason_codes: Vec<&str> = payload
    770             .validation_failures()
    771             .into_iter()
    772             .map(AppPublishValidationFailure::storage_key)
    773             .collect();
    774 
    775         assert_eq!(
    776             reason_codes,
    777             vec![
    778                 "missing_account_id",
    779                 "missing_source",
    780                 "missing_listing_farm_id",
    781                 "missing_listing_farm_pubkey",
    782                 "missing_listing_category",
    783                 "missing_listing_title",
    784                 "missing_listing_unit",
    785                 "missing_listing_price",
    786                 "missing_listing_currency",
    787                 "missing_listing_availability",
    788                 "missing_listing_fulfillment_method",
    789                 "missing_listing_fulfillment_location",
    790             ]
    791         );
    792         assert!(payload.validate().is_err());
    793     }
    794 
    795     #[test]
    796     fn order_request_publish_payload_requires_sdk_publish_inputs() {
    797         let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload {
    798             context: AppPublishContext::new("acct_buyer", "place_personal_order"),
    799             order_id: OrderId::new(),
    800             farm_id: FarmId::new(),
    801             status: Some("needs_action".to_owned()),
    802             order_document_json: None,
    803             listing_addr: Some(String::new()),
    804             listing_event_id: None,
    805             listing_relays: vec![],
    806             buyer_pubkey: None,
    807             seller_pubkey: Some(" ".to_owned()),
    808             items: vec![AppOrderRequestItemPayload {
    809                 product_id: ProductId::new(),
    810                 quantity: 0,
    811             }],
    812             currency_code: None,
    813             total_minor_units: None,
    814             note: None,
    815         });
    816 
    817         let reason_codes: Vec<&str> = payload
    818             .validation_failures()
    819             .into_iter()
    820             .map(AppPublishValidationFailure::storage_key)
    821             .collect();
    822 
    823         assert_eq!(
    824             reason_codes,
    825             vec![
    826                 "missing_order_document",
    827                 "missing_order_listing_address",
    828                 "missing_order_listing_event_id",
    829                 "missing_order_listing_relay",
    830                 "missing_order_buyer_pubkey",
    831                 "missing_order_seller_pubkey",
    832                 "missing_order_items",
    833                 "missing_order_currency",
    834                 "missing_order_total",
    835             ]
    836         );
    837     }
    838 
    839     #[test]
    840     fn order_decision_publish_payload_reports_stable_validation_reason_codes() {
    841         let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload {
    842             context: AppPublishContext::new("", ""),
    843             app_order_id: OrderId::new(),
    844             farm_id: FarmId::new(),
    845             trade_order_id: " ".to_owned(),
    846             request_event_id: String::new(),
    847             listing_event_id: None,
    848             listing_addr: String::new(),
    849             buyer_pubkey: String::new(),
    850             seller_pubkey: String::new(),
    851             decision: AppOrderDecisionPayload::Declined {
    852                 reason: " ".to_owned(),
    853             },
    854         });
    855 
    856         assert_eq!(payload.work_kind().storage_key(), "order_decision");
    857         assert_eq!(
    858             payload.work_kind().sdk_operation(),
    859             ORDER_DECISION_OPERATION_KIND
    860         );
    861         let reason_codes: Vec<&str> = payload
    862             .validation_failures()
    863             .into_iter()
    864             .map(AppPublishValidationFailure::storage_key)
    865             .collect();
    866 
    867         assert_eq!(
    868             reason_codes,
    869             vec![
    870                 "missing_account_id",
    871                 "missing_source",
    872                 "missing_order_trade_order_id",
    873                 "missing_order_request_event_id",
    874                 "missing_order_listing_address",
    875                 "missing_order_buyer_pubkey",
    876                 "missing_order_seller_pubkey",
    877                 "missing_order_decline_reason",
    878             ]
    879         );
    880     }
    881 
    882     #[test]
    883     fn cancellation_publish_payload_reports_stable_validation_reason_codes() {
    884         let order_id = OrderId::new();
    885         let farm_id = FarmId::new();
    886         let cancellation =
    887             AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload {
    888                 context: AppPublishContext::new("", ""),
    889                 app_order_id: order_id,
    890                 farm_id,
    891                 trade_order_id: " ".to_owned(),
    892                 request_event_id: String::new(),
    893                 prev_event_id: String::new(),
    894                 listing_addr: String::new(),
    895                 buyer_pubkey: String::new(),
    896                 seller_pubkey: String::new(),
    897                 reason: " ".to_owned(),
    898             });
    899 
    900         assert_eq!(
    901             cancellation.work_kind().sdk_operation(),
    902             ORDER_CANCELLATION_OPERATION_KIND
    903         );
    904 
    905         let cancellation_reason_codes: Vec<&str> = cancellation
    906             .validation_failures()
    907             .into_iter()
    908             .map(AppPublishValidationFailure::storage_key)
    909             .collect();
    910 
    911         assert_eq!(
    912             cancellation_reason_codes,
    913             vec![
    914                 "missing_account_id",
    915                 "missing_source",
    916                 "missing_order_trade_order_id",
    917                 "missing_order_request_event_id",
    918                 "missing_order_previous_event_id",
    919                 "missing_order_listing_address",
    920                 "missing_order_buyer_pubkey",
    921                 "missing_order_seller_pubkey",
    922                 "missing_order_cancellation_reason",
    923             ]
    924         );
    925 
    926         let operation = PendingSyncOperation::from_publish_payload(
    927             AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload {
    928                 context: AppPublishContext::new("acct_local", "buyer_order_cancellation"),
    929                 app_order_id: order_id,
    930                 farm_id,
    931                 trade_order_id: "order-1".to_owned(),
    932                 request_event_id: "request-event-1".to_owned(),
    933                 prev_event_id: "decision-event-1".to_owned(),
    934                 listing_addr: "30402:seller:listing".to_owned(),
    935                 buyer_pubkey: "buyer".to_owned(),
    936                 seller_pubkey: "seller".to_owned(),
    937                 reason: "buyer cancelled order".to_owned(),
    938             }),
    939             "2026-04-20T18:00:00Z",
    940         )
    941         .expect("typed lifecycle payload should serialize");
    942 
    943         assert_eq!(operation.aggregate, SyncAggregateRef::Order(order_id));
    944         assert_eq!(operation.operation_key, format!("order:{order_id}:upsert"));
    945         assert_eq!(operation.operation, SyncOperationKind::Upsert);
    946         assert_eq!(
    947             operation.publish_payload().expect("payload should parse"),
    948             AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload {
    949                 context: AppPublishContext::new("acct_local", "buyer_order_cancellation"),
    950                 app_order_id: order_id,
    951                 farm_id,
    952                 trade_order_id: "order-1".to_owned(),
    953                 request_event_id: "request-event-1".to_owned(),
    954                 prev_event_id: "decision-event-1".to_owned(),
    955                 listing_addr: "30402:seller:listing".to_owned(),
    956                 buyer_pubkey: "buyer".to_owned(),
    957                 seller_pubkey: "seller".to_owned(),
    958                 reason: "buyer cancelled order".to_owned(),
    959             })
    960         );
    961     }
    962 
    963     #[test]
    964     fn order_revision_publish_payloads_report_stable_validation_reason_codes() {
    965         let order_id = OrderId::new();
    966         let farm_id = FarmId::new();
    967         let economics = revision_economics();
    968         let valid_proposal =
    969             AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload {
    970                 context: AppPublishContext::new("acct_seller", "seller_order_revision_proposal"),
    971                 app_order_id: order_id,
    972                 farm_id,
    973                 trade_order_id: "order-1".to_owned(),
    974                 request_event_id: "request-event-1".to_owned(),
    975                 prev_event_id: "decision-event-1".to_owned(),
    976                 revision_id: "revision-1".to_owned(),
    977                 listing_addr: "30402:seller:listing".to_owned(),
    978                 buyer_pubkey: "buyer".to_owned(),
    979                 seller_pubkey: "seller".to_owned(),
    980                 items: vec![RadrootsOrderItem {
    981                     bin_id: "bin-1".parse().expect("valid bin id"),
    982                     bin_count: 2,
    983                 }],
    984                 economics: economics.clone(),
    985                 reason: "harvest count updated".to_owned(),
    986             });
    987         let invalid_proposal =
    988             AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload {
    989                 context: AppPublishContext::new("", ""),
    990                 app_order_id: order_id,
    991                 farm_id,
    992                 trade_order_id: " ".to_owned(),
    993                 request_event_id: String::new(),
    994                 prev_event_id: String::new(),
    995                 revision_id: String::new(),
    996                 listing_addr: String::new(),
    997                 buyer_pubkey: String::new(),
    998                 seller_pubkey: String::new(),
    999                 items: Vec::new(),
   1000                 economics: economics.clone(),
   1001                 reason: " ".to_owned(),
   1002             });
   1003         let invalid_decision =
   1004             AppPublishPayload::OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload {
   1005                 context: AppPublishContext::new("", ""),
   1006                 app_order_id: order_id,
   1007                 farm_id,
   1008                 trade_order_id: " ".to_owned(),
   1009                 request_event_id: String::new(),
   1010                 prev_event_id: String::new(),
   1011                 revision_id: String::new(),
   1012                 listing_addr: String::new(),
   1013                 buyer_pubkey: String::new(),
   1014                 seller_pubkey: String::new(),
   1015                 decision: RadrootsOrderRevisionOutcome::Declined {
   1016                     reason: " ".to_owned(),
   1017                 },
   1018             });
   1019 
   1020         assert_eq!(
   1021             valid_proposal.work_kind().sdk_operation(),
   1022             ORDER_REVISION_PROPOSAL_OPERATION_KIND
   1023         );
   1024         assert_eq!(valid_proposal.validation_failures(), Vec::new());
   1025         assert_eq!(
   1026             invalid_decision.work_kind().sdk_operation(),
   1027             ORDER_REVISION_DECISION_OPERATION_KIND
   1028         );
   1029 
   1030         let proposal_reason_codes: Vec<&str> = invalid_proposal
   1031             .validation_failures()
   1032             .into_iter()
   1033             .map(AppPublishValidationFailure::storage_key)
   1034             .collect();
   1035         let decision_reason_codes: Vec<&str> = invalid_decision
   1036             .validation_failures()
   1037             .into_iter()
   1038             .map(AppPublishValidationFailure::storage_key)
   1039             .collect();
   1040 
   1041         assert_eq!(
   1042             proposal_reason_codes,
   1043             vec![
   1044                 "missing_account_id",
   1045                 "missing_source",
   1046                 "missing_order_trade_order_id",
   1047                 "missing_order_request_event_id",
   1048                 "missing_order_previous_event_id",
   1049                 "missing_order_listing_address",
   1050                 "missing_order_buyer_pubkey",
   1051                 "missing_order_seller_pubkey",
   1052                 "missing_order_revision_id",
   1053                 "missing_order_revision_items",
   1054                 "missing_order_revision_reason",
   1055             ]
   1056         );
   1057         assert_eq!(
   1058             decision_reason_codes,
   1059             vec![
   1060                 "missing_account_id",
   1061                 "missing_source",
   1062                 "missing_order_trade_order_id",
   1063                 "missing_order_request_event_id",
   1064                 "missing_order_previous_event_id",
   1065                 "missing_order_listing_address",
   1066                 "missing_order_buyer_pubkey",
   1067                 "missing_order_seller_pubkey",
   1068                 "missing_order_revision_id",
   1069                 "missing_order_revision_decision_reason",
   1070             ]
   1071         );
   1072     }
   1073 
   1074     #[test]
   1075     fn existing_raw_payload_outbox_work_remains_local_save_compatible() {
   1076         let pending_operation = PendingSyncOperation {
   1077             operation_key: "product:greens:upsert".to_owned(),
   1078             aggregate: SyncAggregateRef::Product(ProductId::new()),
   1079             operation: SyncOperationKind::Upsert,
   1080             payload_json: "{\"title\":\"greens\"}".to_owned(),
   1081             created_at: "2026-04-17T19:32:00Z".to_owned(),
   1082             available_at: "2026-04-17T19:32:00Z".to_owned(),
   1083             attempt_count: 0,
   1084             state: PendingSyncOperationState::Pending,
   1085             last_error_message: None,
   1086         };
   1087 
   1088         assert!(!pending_operation.is_retry());
   1089         assert!(pending_operation.publish_payload().is_err());
   1090     }
   1091 
   1092     fn revision_economics() -> RadrootsOrderEconomics {
   1093         serde_json::from_value(json!({
   1094             "quote_id": "quote-revision-1",
   1095             "quote_version": 2,
   1096             "pricing_basis": "listing_event",
   1097             "currency": "USD",
   1098             "items": [{
   1099                 "bin_id": "bin-1",
   1100                 "bin_count": 2,
   1101                 "quantity_amount": "1",
   1102                 "quantity_unit": "each",
   1103                 "unit_price_amount": "8",
   1104                 "unit_price_currency": "USD",
   1105                 "line_subtotal": {
   1106                     "amount": "16",
   1107                     "currency": "USD"
   1108                 }
   1109             }],
   1110             "discounts": [],
   1111             "adjustments": [],
   1112             "subtotal": {
   1113                 "amount": "16",
   1114                 "currency": "USD"
   1115             },
   1116             "discount_total": {
   1117                 "amount": "0",
   1118                 "currency": "USD"
   1119             },
   1120             "adjustment_total": {
   1121                 "amount": "0",
   1122                 "currency": "USD"
   1123             },
   1124             "total": {
   1125                 "amount": "16",
   1126                 "currency": "USD"
   1127             }
   1128         }))
   1129         .expect("revision economics fixture should decode")
   1130     }
   1131 }