lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

order_work.rs (30744B)


      1 use serde_json::Value;
      2 
      3 use crate::LocalEventsError;
      4 use crate::models::validate_non_empty;
      5 
      6 pub const BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND: &str = "buyer_order_request_v1";
      7 pub const BUYER_ORDER_REQUEST_DOCUMENT_KIND: &str = "order_draft_v1";
      8 pub const BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
      9 pub const BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP: &str = "app_unresolved";
     10 
     11 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     12 pub enum BuyerOrderRequestSupportState {
     13     Supported,
     14     Unsupported,
     15 }
     16 
     17 impl BuyerOrderRequestSupportState {
     18     pub fn as_str(self) -> &'static str {
     19         match self {
     20             Self::Supported => "supported",
     21             Self::Unsupported => "unsupported",
     22         }
     23     }
     24 }
     25 
     26 #[derive(Clone, Debug, Eq, PartialEq)]
     27 pub struct BuyerOrderRequestLocalWorkValidation {
     28     pub order_id: String,
     29     pub support_state: BuyerOrderRequestSupportState,
     30     pub support_issues: Vec<String>,
     31 }
     32 
     33 pub fn buyer_order_request_local_work_record_id(
     34     order_id: &str,
     35 ) -> Result<String, LocalEventsError> {
     36     let order_id = order_id.trim();
     37     validate_non_empty("order_id", order_id)?;
     38     Ok(format!("app:local_work:order_request:{order_id}"))
     39 }
     40 
     41 pub fn validate_buyer_order_request_local_work_payload(
     42     payload: &Value,
     43 ) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> {
     44     validate_string_field(
     45         payload,
     46         &["record_kind"],
     47         BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND,
     48     )?;
     49     validate_string_field(payload, &["scope"], "app")?;
     50     validate_string_field(
     51         payload,
     52         &["document", "kind"],
     53         BUYER_ORDER_REQUEST_DOCUMENT_KIND,
     54     )?;
     55     validate_bool_field(payload, &["currentness", "current"], true)?;
     56     validate_string_field(payload, &["currentness", "source"], "app_sqlite_order")?;
     57 
     58     let order_id = validate_required_string(payload, &["document", "order", "order_id"])?;
     59     let currentness_order_id = validate_required_string(payload, &["currentness", "order_id"])?;
     60     if currentness_order_id != order_id {
     61         return Err(invalid_field(
     62             "currentness.order_id",
     63             "must match document.order.order_id",
     64         ));
     65     }
     66     validate_required_string(payload, &["currentness", "record_id"])?;
     67     validate_positive_i64(payload, &["currentness", "created_at_ms"])?;
     68     validate_required_string(payload, &["currentness", "order_updated_at"])?;
     69 
     70     let (support_state, support_issues) = validate_support_status(payload)?;
     71     validate_exportability(payload, support_state)?;
     72     validate_order_identity(payload, support_state)?;
     73     validate_order_items(payload)?;
     74     validate_order_economics(payload)?;
     75 
     76     Ok(BuyerOrderRequestLocalWorkValidation {
     77         order_id: order_id.to_owned(),
     78         support_state,
     79         support_issues,
     80     })
     81 }
     82 
     83 pub fn validate_supported_buyer_order_request_local_work_payload(
     84     payload: &Value,
     85 ) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> {
     86     let validation = validate_buyer_order_request_local_work_payload(payload)?;
     87     if validation.support_state != BuyerOrderRequestSupportState::Supported {
     88         return Err(invalid_field(
     89             "support_status.state",
     90             "must be supported for exportable app order work",
     91         ));
     92     }
     93     Ok(validation)
     94 }
     95 
     96 pub fn validate_unsupported_buyer_order_request_local_work_payload(
     97     payload: &Value,
     98 ) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> {
     99     let validation = validate_buyer_order_request_local_work_payload(payload)?;
    100     if validation.support_state != BuyerOrderRequestSupportState::Unsupported {
    101         return Err(invalid_field(
    102             "support_status.state",
    103             "must be unsupported for unsupported app order work",
    104         ));
    105     }
    106     Ok(validation)
    107 }
    108 
    109 fn validate_support_status(
    110     payload: &Value,
    111 ) -> Result<(BuyerOrderRequestSupportState, Vec<String>), LocalEventsError> {
    112     let state = validate_required_string(payload, &["support_status", "state"])?;
    113     let issues = support_issues(payload)?;
    114     match state {
    115         "supported" => {
    116             if !issues.is_empty() {
    117                 return Err(invalid_field(
    118                     "support_status.issues",
    119                     "must be empty when support_status.state is supported",
    120                 ));
    121             }
    122             Ok((BuyerOrderRequestSupportState::Supported, issues))
    123         }
    124         "unsupported" => {
    125             if issues.is_empty() {
    126                 return Err(invalid_field(
    127                     "support_status.issues",
    128                     "must contain at least one issue when support_status.state is unsupported",
    129                 ));
    130             }
    131             Ok((BuyerOrderRequestSupportState::Unsupported, issues))
    132         }
    133         _ => Err(invalid_field(
    134             "support_status.state",
    135             "must be supported or unsupported",
    136         )),
    137     }
    138 }
    139 
    140 fn validate_exportability(
    141     payload: &Value,
    142     support_state: BuyerOrderRequestSupportState,
    143 ) -> Result<(), LocalEventsError> {
    144     let state = validate_required_string(payload, &["exportability", "state"])?;
    145     match state {
    146         "exportable" => {
    147             validate_string_field(
    148                 payload,
    149                 &["document", "buyer_actor", "source"],
    150                 BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT,
    151             )?;
    152             validate_buyer_pubkey(payload)?;
    153         }
    154         "identity_unresolved" => {
    155             validate_required_string(payload, &["exportability", "reason"])?;
    156             validate_string_field(
    157                 payload,
    158                 &["document", "buyer_actor", "source"],
    159                 BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP,
    160             )?;
    161             if support_state == BuyerOrderRequestSupportState::Supported {
    162                 return Err(invalid_field(
    163                     "exportability.state",
    164                     "supported app order work must be exportable",
    165                 ));
    166             }
    167         }
    168         _ => {
    169             return Err(invalid_field(
    170                 "exportability.state",
    171                 "must be exportable or identity_unresolved",
    172             ));
    173         }
    174     }
    175     Ok(())
    176 }
    177 
    178 fn validate_order_identity(
    179     payload: &Value,
    180     support_state: BuyerOrderRequestSupportState,
    181 ) -> Result<(), LocalEventsError> {
    182     validate_required_string(payload, &["document", "order", "listing_addr"])?;
    183     validate_required_string(payload, &["document", "order", "listing_event_id"])?;
    184     validate_required_string(payload, &["document", "order", "seller_pubkey"])?;
    185     if support_state == BuyerOrderRequestSupportState::Supported {
    186         validate_buyer_pubkey(payload)?;
    187     }
    188     Ok(())
    189 }
    190 
    191 fn validate_buyer_pubkey(payload: &Value) -> Result<(), LocalEventsError> {
    192     let order_buyer_pubkey =
    193         validate_required_string(payload, &["document", "order", "buyer_pubkey"])?;
    194     let actor_buyer_pubkey =
    195         validate_required_string(payload, &["document", "buyer_actor", "pubkey"])?;
    196     if order_buyer_pubkey != actor_buyer_pubkey {
    197         return Err(invalid_field(
    198             "document.buyer_actor.pubkey",
    199             "must match document.order.buyer_pubkey",
    200         ));
    201     }
    202     Ok(())
    203 }
    204 
    205 fn validate_order_items(payload: &Value) -> Result<(), LocalEventsError> {
    206     let items = required_array(payload, &["document", "order", "items"])?;
    207     if items.is_empty() {
    208         return Err(invalid_field(
    209             "document.order.items",
    210             "must contain at least one item",
    211         ));
    212     }
    213     for (index, item) in items.iter().enumerate() {
    214         validate_required_string(item, &["bin_id"]).map_err(|_| {
    215             invalid_field_at(
    216                 format!("document.order.items[{index}].bin_id"),
    217                 "is required",
    218             )
    219         })?;
    220         validate_positive_u64(item, &["bin_count"]).map_err(|_| {
    221             invalid_field_at(
    222                 format!("document.order.items[{index}].bin_count"),
    223                 "must be positive",
    224             )
    225         })?;
    226     }
    227     Ok(())
    228 }
    229 
    230 fn validate_order_economics(payload: &Value) -> Result<(), LocalEventsError> {
    231     let economics = value_at(payload, &["document", "order", "economics"]).ok_or_else(|| {
    232         invalid_field("document.order.economics", "is required for app order work")
    233     })?;
    234     if !economics.is_object() {
    235         return Err(invalid_field(
    236             "document.order.economics",
    237             "must be an object",
    238         ));
    239     }
    240     validate_string_field(economics, &["pricing_basis"], "listing_event")?;
    241     let currency = validate_required_string(economics, &["currency"])?;
    242     validate_currency("document.order.economics.currency", currency)?;
    243     let economics_items = required_array(economics, &["items"])?;
    244     let order_items = required_array(payload, &["document", "order", "items"])?;
    245     if economics_items.is_empty() {
    246         return Err(invalid_field(
    247             "document.order.economics.items",
    248             "must contain at least one item",
    249         ));
    250     }
    251     if economics_items.len() != order_items.len() {
    252         return Err(invalid_field(
    253             "document.order.economics.items",
    254             "must match document.order.items length",
    255         ));
    256     }
    257     for (index, item) in economics_items.iter().enumerate() {
    258         let order_item = &order_items[index];
    259         let economics_bin_id = validate_required_string(item, &["bin_id"]).map_err(|_| {
    260             invalid_field_at(
    261                 format!("document.order.economics.items[{index}].bin_id"),
    262                 "is required",
    263             )
    264         })?;
    265         let order_bin_id = validate_required_string(order_item, &["bin_id"])?;
    266         if economics_bin_id != order_bin_id {
    267             return Err(invalid_field_at(
    268                 format!("document.order.economics.items[{index}].bin_id"),
    269                 "must match document.order.items bin_id",
    270             ));
    271         }
    272         let economics_bin_count = validate_positive_u64(item, &["bin_count"]).map_err(|_| {
    273             invalid_field_at(
    274                 format!("document.order.economics.items[{index}].bin_count"),
    275                 "must be positive",
    276             )
    277         })?;
    278         let order_bin_count = validate_positive_u64(order_item, &["bin_count"])?;
    279         if economics_bin_count != order_bin_count {
    280             return Err(invalid_field_at(
    281                 format!("document.order.economics.items[{index}].bin_count"),
    282                 "must match document.order.items bin_count",
    283             ));
    284         }
    285         validate_required_string(item, &["quantity_amount"]).map_err(|_| {
    286             invalid_field_at(
    287                 format!("document.order.economics.items[{index}].quantity_amount"),
    288                 "is required",
    289             )
    290         })?;
    291         validate_required_string(item, &["quantity_unit"]).map_err(|_| {
    292             invalid_field_at(
    293                 format!("document.order.economics.items[{index}].quantity_unit"),
    294                 "is required",
    295             )
    296         })?;
    297         validate_required_string(item, &["unit_price_amount"]).map_err(|_| {
    298             invalid_field_at(
    299                 format!("document.order.economics.items[{index}].unit_price_amount"),
    300                 "is required",
    301             )
    302         })?;
    303         let unit_price_currency = validate_required_string(item, &["unit_price_currency"])?;
    304         if unit_price_currency != currency {
    305             return Err(invalid_field_at(
    306                 format!("document.order.economics.items[{index}].unit_price_currency"),
    307                 "must match document.order.economics.currency",
    308             ));
    309         }
    310         validate_money(item, &["line_subtotal"], currency)?;
    311     }
    312     validate_money(economics, &["subtotal"], currency)?;
    313     validate_money(economics, &["discount_total"], currency)?;
    314     validate_money(economics, &["adjustment_total"], currency)?;
    315     validate_money(economics, &["total"], currency)?;
    316     Ok(())
    317 }
    318 
    319 fn validate_money(payload: &Value, path: &[&str], currency: &str) -> Result<(), LocalEventsError> {
    320     let Some(money) = value_at(payload, path) else {
    321         return Err(missing_field(path));
    322     };
    323     validate_required_string(money, &["amount"])?;
    324     let money_currency = validate_required_string(money, &["currency"])?;
    325     if money_currency != currency {
    326         return Err(invalid_field(
    327             &format!("{}.currency", path.join(".")),
    328             "must match currency",
    329         ));
    330     }
    331     Ok(())
    332 }
    333 
    334 fn validate_string_field(
    335     payload: &Value,
    336     path: &[&str],
    337     expected: &str,
    338 ) -> Result<(), LocalEventsError> {
    339     let Some(value) = value_at(payload, path).and_then(Value::as_str) else {
    340         return Err(missing_field(path));
    341     };
    342     if value != expected {
    343         return Err(invalid_field(
    344             &path.join("."),
    345             &format!("must be `{expected}`"),
    346         ));
    347     }
    348     Ok(())
    349 }
    350 
    351 fn validate_required_string<'a>(
    352     payload: &'a Value,
    353     path: &[&str],
    354 ) -> Result<&'a str, LocalEventsError> {
    355     let Some(value) = value_at(payload, path).and_then(Value::as_str) else {
    356         return Err(missing_field(path));
    357     };
    358     validate_non_empty(&path.join("."), value)?;
    359     Ok(value.trim())
    360 }
    361 
    362 fn validate_bool_field(
    363     payload: &Value,
    364     path: &[&str],
    365     expected: bool,
    366 ) -> Result<(), LocalEventsError> {
    367     let Some(value) = value_at(payload, path).and_then(Value::as_bool) else {
    368         return Err(missing_field(path));
    369     };
    370     if value != expected {
    371         return Err(invalid_field(
    372             &path.join("."),
    373             &format!("must be `{expected}`"),
    374         ));
    375     }
    376     Ok(())
    377 }
    378 
    379 fn validate_positive_i64(payload: &Value, path: &[&str]) -> Result<(), LocalEventsError> {
    380     match value_at(payload, path).and_then(Value::as_i64) {
    381         Some(value) if value > 0 => Ok(()),
    382         _ => Err(invalid_field(&path.join("."), "must be positive")),
    383     }
    384 }
    385 
    386 fn validate_positive_u64(payload: &Value, path: &[&str]) -> Result<u64, LocalEventsError> {
    387     match value_at(payload, path).and_then(Value::as_u64) {
    388         Some(value) if value > 0 => Ok(value),
    389         _ => Err(invalid_field(&path.join("."), "must be positive")),
    390     }
    391 }
    392 
    393 fn validate_currency(field: &str, value: &str) -> Result<(), LocalEventsError> {
    394     if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_uppercase()) {
    395         return Err(invalid_field(
    396             field,
    397             "must be an uppercase ISO currency code",
    398         ));
    399     }
    400     Ok(())
    401 }
    402 
    403 fn required_array<'a>(
    404     payload: &'a Value,
    405     path: &[&str],
    406 ) -> Result<&'a Vec<Value>, LocalEventsError> {
    407     let Some(value) = value_at(payload, path).and_then(Value::as_array) else {
    408         return Err(missing_field(path));
    409     };
    410     Ok(value)
    411 }
    412 
    413 fn support_issues(payload: &Value) -> Result<Vec<String>, LocalEventsError> {
    414     let issues = required_array(payload, &["support_status", "issues"])?;
    415     let mut parsed = Vec::with_capacity(issues.len());
    416     for (index, issue) in issues.iter().enumerate() {
    417         let Some(issue) = issue.as_str() else {
    418             return Err(invalid_field_at(
    419                 format!("support_status.issues[{index}]"),
    420                 "must be a string",
    421             ));
    422         };
    423         validate_non_empty("support_status.issues", issue)?;
    424         parsed.push(issue.trim().to_owned());
    425     }
    426     Ok(parsed)
    427 }
    428 
    429 fn value_at<'a>(payload: &'a Value, path: &[&str]) -> Option<&'a Value> {
    430     let mut current = payload;
    431     for part in path {
    432         current = current.get(*part)?;
    433     }
    434     Some(current)
    435 }
    436 
    437 fn missing_field(path: &[&str]) -> LocalEventsError {
    438     invalid_field(&path.join("."), "is required")
    439 }
    440 
    441 fn invalid_field(field: &str, requirement: &str) -> LocalEventsError {
    442     LocalEventsError::InvalidRecord(format!("local order field `{field}` {requirement}"))
    443 }
    444 
    445 fn invalid_field_at(field: String, requirement: &str) -> LocalEventsError {
    446     LocalEventsError::InvalidRecord(format!("local order field `{field}` {requirement}"))
    447 }
    448 
    449 #[cfg(test)]
    450 mod tests {
    451     use serde_json::{Value, json};
    452 
    453     use super::*;
    454 
    455     #[test]
    456     fn support_state_labels_and_record_id_validation_are_stable() {
    457         assert_eq!(
    458             BuyerOrderRequestSupportState::Supported.as_str(),
    459             "supported"
    460         );
    461         assert_eq!(
    462             BuyerOrderRequestSupportState::Unsupported.as_str(),
    463             "unsupported"
    464         );
    465         assert_eq!(
    466             buyer_order_request_local_work_record_id(" ord-a ").expect("record id"),
    467             "app:local_work:order_request:ord-a"
    468         );
    469         assert_error_contains(
    470             buyer_order_request_local_work_record_id(" "),
    471             "order_id must not be empty",
    472         );
    473     }
    474 
    475     #[test]
    476     fn private_validation_helpers_cover_successful_payload() {
    477         let payload = supported_payload();
    478 
    479         assert_eq!(
    480             validate_support_status(&payload).expect("support status"),
    481             (
    482                 BuyerOrderRequestSupportState::Supported,
    483                 Vec::<String>::new()
    484             )
    485         );
    486         validate_supported_buyer_order_request_local_work_payload(&payload)
    487             .expect("supported payload");
    488         validate_exportability(&payload, BuyerOrderRequestSupportState::Supported)
    489             .expect("exportability");
    490         validate_order_identity(&payload, BuyerOrderRequestSupportState::Supported)
    491             .expect("identity");
    492         validate_order_items(&payload).expect("items");
    493         validate_order_economics(&payload).expect("economics");
    494         assert_eq!(
    495             validate_required_string(&payload, &["document", "order", "order_id"])
    496                 .expect("order id"),
    497             "ord_1"
    498         );
    499         validate_bool_field(&payload, &["currentness", "current"], true).expect("bool");
    500         assert_eq!(
    501             support_issues(&payload).expect("support issues"),
    502             Vec::<String>::new()
    503         );
    504         assert!(value_at(&payload, &["document", "order"]).is_some());
    505     }
    506 
    507     #[test]
    508     fn payload_validation_rejects_top_level_contract_drift() {
    509         let mut wrong_kind = supported_payload();
    510         wrong_kind["record_kind"] = json!("other");
    511         assert_invalid(wrong_kind, "record_kind");
    512 
    513         let mut missing_scope = supported_payload();
    514         missing_scope["scope"] = Value::Null;
    515         assert_invalid(missing_scope, "scope");
    516 
    517         let mut wrong_document_kind = supported_payload();
    518         wrong_document_kind["document"]["kind"] = json!("other");
    519         assert_invalid(wrong_document_kind, "document.kind");
    520 
    521         let mut wrong_currentness_source = supported_payload();
    522         wrong_currentness_source["currentness"]["source"] = json!("other");
    523         assert_invalid(wrong_currentness_source, "currentness.source");
    524 
    525         let mut missing_order_updated = supported_payload();
    526         missing_order_updated["currentness"]["order_updated_at"] = Value::Null;
    527         assert_invalid(missing_order_updated, "order_updated_at");
    528 
    529         let mut bad_created_at = supported_payload();
    530         bad_created_at["currentness"]["created_at_ms"] = json!(0);
    531         assert_invalid(bad_created_at, "created_at_ms");
    532     }
    533 
    534     #[test]
    535     fn support_and_exportability_rejections_cover_private_branches() {
    536         let mut invalid_state = supported_payload();
    537         invalid_state["support_status"]["state"] = json!("partial");
    538         assert_invalid(invalid_state, "support_status.state");
    539 
    540         let mut issue_not_string = supported_payload();
    541         issue_not_string["support_status"] = json!({
    542             "state": "unsupported",
    543             "issues": [42]
    544         });
    545         assert_invalid(issue_not_string, "support_status.issues[0]");
    546 
    547         let mut issue_empty = supported_payload();
    548         issue_empty["support_status"] = json!({
    549             "state": "unsupported",
    550             "issues": [" "]
    551         });
    552         assert_invalid(issue_empty, "support_status.issues");
    553 
    554         let mut supported_but_unresolved = unsupported_payload();
    555         supported_but_unresolved["support_status"] = json!({
    556             "state": "supported",
    557             "issues": []
    558         });
    559         assert_invalid(supported_but_unresolved, "exportability.state");
    560 
    561         let mut unknown_exportability = supported_payload();
    562         unknown_exportability["exportability"]["state"] = json!("queued");
    563         assert_invalid(unknown_exportability, "exportability.state");
    564 
    565         let mut missing_reason = unsupported_payload();
    566         missing_reason["exportability"]["reason"] = Value::Null;
    567         assert_invalid(missing_reason, "exportability.reason");
    568 
    569         let mut wrong_actor_source = unsupported_payload();
    570         wrong_actor_source["document"]["buyer_actor"]["source"] =
    571             json!(BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT);
    572         assert_invalid(wrong_actor_source, "buyer_actor.source");
    573 
    574         let mut mismatched_buyer = supported_payload();
    575         mismatched_buyer["document"]["buyer_actor"]["pubkey"] = json!("other");
    576         assert_invalid(mismatched_buyer, "buyer_actor.pubkey");
    577 
    578         let supported_error =
    579             validate_unsupported_buyer_order_request_local_work_payload(&supported_payload())
    580                 .expect_err("supported payload is not unsupported");
    581         assert!(supported_error.to_string().contains("support_status.state"));
    582     }
    583 
    584     #[test]
    585     fn item_and_economics_rejections_cover_private_branches() {
    586         let mut economics_not_object = supported_payload();
    587         economics_not_object["document"]["order"]["economics"] = json!("bad");
    588         assert_invalid(economics_not_object, "economics");
    589 
    590         let mut bad_pricing_basis = supported_payload();
    591         bad_pricing_basis["document"]["order"]["economics"]["pricing_basis"] = json!("manual");
    592         assert_invalid(bad_pricing_basis, "pricing_basis");
    593 
    594         let mut bad_currency = supported_payload();
    595         bad_currency["document"]["order"]["economics"]["currency"] = json!("usd");
    596         assert_invalid(bad_currency, "currency");
    597 
    598         let mut bad_currency_length = supported_payload();
    599         bad_currency_length["document"]["order"]["economics"]["currency"] = json!("US");
    600         assert_invalid(bad_currency_length, "currency");
    601 
    602         let mut missing_economics = supported_payload();
    603         missing_economics["document"]["order"]
    604             .as_object_mut()
    605             .expect("order object")
    606             .remove("economics");
    607         assert_invalid(missing_economics, "economics");
    608 
    609         let mut economics_items_missing = supported_payload();
    610         economics_items_missing["document"]["order"]["economics"]["items"] = Value::Null;
    611         assert_invalid(economics_items_missing, "items");
    612 
    613         let mut economics_items_short = supported_payload();
    614         economics_items_short["document"]["order"]["economics"]["items"] = json!([]);
    615         assert_invalid(economics_items_short, "economics.items");
    616 
    617         let mut economics_items_long = supported_payload();
    618         economics_items_long["document"]["order"]["economics"]["items"] = json!([
    619             {
    620                 "bin_id": "dozen-eggs",
    621                 "bin_count": 2,
    622                 "quantity_amount": "1",
    623                 "quantity_unit": "dozen",
    624                 "unit_price_amount": "8.00",
    625                 "unit_price_currency": "USD",
    626                 "line_subtotal": {
    627                     "amount": "16.00",
    628                     "currency": "USD"
    629                 }
    630             },
    631             {
    632                 "bin_id": "half-dozen-eggs",
    633                 "bin_count": 1
    634             }
    635         ]);
    636         assert_invalid(economics_items_long, "economics.items");
    637 
    638         let mut economics_bin_missing = supported_payload();
    639         economics_bin_missing["document"]["order"]["economics"]["items"][0]["bin_id"] = Value::Null;
    640         assert_invalid(economics_bin_missing, "economics.items[0].bin_id");
    641 
    642         let mut economics_count_bad = supported_payload();
    643         economics_count_bad["document"]["order"]["economics"]["items"][0]["bin_count"] = json!(0);
    644         assert_invalid(economics_count_bad, "economics.items[0].bin_count");
    645 
    646         let mut order_count_mismatch = supported_payload();
    647         order_count_mismatch["document"]["order"]["economics"]["items"][0]["bin_count"] = json!(3);
    648         assert_invalid(order_count_mismatch, "economics.items[0].bin_count");
    649 
    650         let mut quantity_amount_missing = supported_payload();
    651         quantity_amount_missing["document"]["order"]["economics"]["items"][0]["quantity_amount"] =
    652             Value::Null;
    653         assert_invalid(quantity_amount_missing, "quantity_amount");
    654 
    655         let mut quantity_unit_missing = supported_payload();
    656         quantity_unit_missing["document"]["order"]["economics"]["items"][0]["quantity_unit"] =
    657             Value::Null;
    658         assert_invalid(quantity_unit_missing, "quantity_unit");
    659 
    660         let mut unit_price_amount_missing = supported_payload();
    661         unit_price_amount_missing["document"]["order"]["economics"]["items"][0]["unit_price_amount"] =
    662             Value::Null;
    663         assert_invalid(unit_price_amount_missing, "unit_price_amount");
    664 
    665         let mut line_subtotal_missing = supported_payload();
    666         line_subtotal_missing["document"]["order"]["economics"]["items"][0]["line_subtotal"] =
    667             Value::Null;
    668         assert_invalid(line_subtotal_missing, "amount");
    669 
    670         let mut missing_line_subtotal = supported_payload();
    671         missing_line_subtotal["document"]["order"]["economics"]["items"][0]
    672             .as_object_mut()
    673             .expect("economics item")
    674             .remove("line_subtotal");
    675         assert_invalid(missing_line_subtotal, "line_subtotal");
    676 
    677         let mut line_subtotal_currency = supported_payload();
    678         line_subtotal_currency["document"]["order"]["economics"]["items"][0]["line_subtotal"]["currency"] =
    679             json!("CAD");
    680         assert_invalid(line_subtotal_currency, "line_subtotal.currency");
    681 
    682         let mut subtotal_currency = supported_payload();
    683         subtotal_currency["document"]["order"]["economics"]["subtotal"]["currency"] = json!("CAD");
    684         assert_invalid(subtotal_currency, "subtotal.currency");
    685 
    686         let mut order_item_missing = supported_payload();
    687         order_item_missing["document"]["order"]["items"] = Value::Null;
    688         assert_invalid(order_item_missing, "document.order.items");
    689 
    690         let mut missing_order_bin = supported_payload();
    691         missing_order_bin["document"]["order"]["items"][0]["bin_id"] = Value::Null;
    692         assert_error_contains(validate_order_items(&missing_order_bin), "items[0].bin_id");
    693     }
    694 
    695     fn supported_payload() -> Value {
    696         json!({
    697             "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND,
    698             "scope": "app",
    699             "exportability": {
    700                 "state": "exportable"
    701             },
    702             "support_status": {
    703                 "state": "supported",
    704                 "issues": []
    705             },
    706             "currentness": {
    707                 "current": true,
    708                 "source": "app_sqlite_order",
    709                 "record_id": "app:local_work:order_request:ord_1",
    710                 "order_id": "ord_1",
    711                 "order_updated_at": "2026-05-24T12:00:00Z",
    712                 "created_at_ms": 1777777777000_i64
    713             },
    714             "document": {
    715                 "kind": BUYER_ORDER_REQUEST_DOCUMENT_KIND,
    716                 "order": {
    717                     "order_id": "ord_1",
    718                     "listing_addr": "30402:seller_pubkey:listing_key",
    719                     "listing_event_id": "event-listing-1",
    720                     "buyer_pubkey": "buyer_pubkey",
    721                     "seller_pubkey": "seller_pubkey",
    722                     "items": [
    723                         {
    724                             "bin_id": "dozen-eggs",
    725                             "bin_count": 2
    726                         }
    727                     ],
    728                     "economics": {
    729                         "pricing_basis": "listing_event",
    730                         "currency": "USD",
    731                         "items": [
    732                             {
    733                                 "bin_id": "dozen-eggs",
    734                                 "bin_count": 2,
    735                                 "quantity_amount": "1",
    736                                 "quantity_unit": "dozen",
    737                                 "unit_price_amount": "8.00",
    738                                 "unit_price_currency": "USD",
    739                                 "line_subtotal": {
    740                                     "amount": "16.00",
    741                                     "currency": "USD"
    742                                 }
    743                             }
    744                         ],
    745                         "subtotal": {
    746                             "amount": "16.00",
    747                             "currency": "USD"
    748                         },
    749                         "discount_total": {
    750                             "amount": "0",
    751                             "currency": "USD"
    752                         },
    753                         "adjustment_total": {
    754                             "amount": "0",
    755                             "currency": "USD"
    756                         },
    757                         "total": {
    758                             "amount": "16.00",
    759                             "currency": "USD"
    760                         }
    761                     }
    762                 },
    763                 "buyer_actor": {
    764                     "account_id": "buyer-account",
    765                     "pubkey": "buyer_pubkey",
    766                     "source": BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT
    767                 }
    768             }
    769         })
    770     }
    771 
    772     fn unsupported_payload() -> Value {
    773         let mut payload = supported_payload();
    774         payload["exportability"] = json!({
    775             "state": "identity_unresolved",
    776             "reason": "canonical_hex_pubkey_required"
    777         });
    778         payload["support_status"] = json!({
    779             "state": "unsupported",
    780             "issues": ["buyer_pubkey_required"]
    781         });
    782         payload["document"]["order"]["buyer_pubkey"] = json!("");
    783         payload["document"]["buyer_actor"]["pubkey"] = json!("");
    784         payload["document"]["buyer_actor"]["source"] =
    785             json!(BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP);
    786         payload
    787     }
    788 
    789     fn assert_invalid(payload: Value, expected: &str) {
    790         assert_error_contains(
    791             validate_buyer_order_request_local_work_payload(&payload),
    792             expected,
    793         );
    794     }
    795 
    796     fn assert_error_contains<T: std::fmt::Debug>(
    797         result: Result<T, LocalEventsError>,
    798         expected: &str,
    799     ) {
    800         let error = result.expect_err("expected validation error");
    801         assert!(
    802             error.to_string().contains(expected),
    803             "expected error to contain {expected}, got {error}"
    804         );
    805     }
    806 }