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 }