lib

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

commit 866e01b3ff3c3c05235d4899e72a3e542020b295
parent 3f2c36b058f3b550bce35980b4c382d208982cfe
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 01:43:23 +0000

trade: extend order lifecycle reducer

Diffstat:
Mcrates/trade/src/order.rs | 1218++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 1175 insertions(+), 43 deletions(-)

diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -8,8 +8,9 @@ use alloc::{ use radroots_events::kinds::KIND_LISTING; use radroots_events::trade::{ - RadrootsActiveTradeFulfillmentState, RadrootsTradeFulfillmentUpdated, - RadrootsTradeInventoryCommitment, RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderDecision, + RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, + RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, + RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; use radroots_events_codec::trade::RadrootsTradeListingAddress as TradeListingAddress; @@ -65,11 +66,34 @@ pub struct RadrootsActiveOrderFulfillmentRecord { } #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderCancellationRecord { + pub event_id: String, + pub author_pubkey: String, + pub counterparty_pubkey: String, + pub root_event_id: String, + pub prev_event_id: String, + pub payload: RadrootsTradeOrderCancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsActiveOrderReceiptRecord { + pub event_id: String, + pub author_pubkey: String, + pub counterparty_pubkey: String, + pub root_event_id: String, + pub prev_event_id: String, + pub payload: RadrootsTradeBuyerReceipt, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsActiveOrderStatus { Missing, Requested, Accepted, Declined, + Cancelled, + Completed, + Disputed, Invalid, } @@ -110,6 +134,30 @@ pub enum RadrootsActiveOrderReducerIssue { FulfillmentStatusNotPublishable { event_id: String }, FulfillmentUnsupportedTransition { event_id: String }, ForkedFulfillments { event_ids: Vec<String> }, + CancellationWithoutCancellableOrder { event_id: String }, + CancellationPayloadInvalid { event_id: String }, + CancellationOrderIdMismatch { event_id: String }, + CancellationAuthorMismatch { event_id: String }, + CancellationCounterpartyMismatch { event_id: String }, + CancellationBuyerMismatch { event_id: String }, + CancellationSellerMismatch { event_id: String }, + CancellationListingAddressInvalid { event_id: String }, + CancellationListingMismatch { event_id: String }, + CancellationRootMismatch { event_id: String }, + CancellationPreviousMismatch { event_id: String }, + CancellationAfterFulfillment { event_id: String }, + ReceiptWithoutEligibleFulfillment { event_id: String }, + ReceiptPayloadInvalid { event_id: String }, + ReceiptOrderIdMismatch { event_id: String }, + ReceiptAuthorMismatch { event_id: String }, + ReceiptCounterpartyMismatch { event_id: String }, + ReceiptBuyerMismatch { event_id: String }, + ReceiptSellerMismatch { event_id: String }, + ReceiptListingAddressInvalid { event_id: String }, + ReceiptListingMismatch { event_id: String }, + ReceiptRootMismatch { event_id: String }, + ReceiptPreviousMismatch { event_id: String }, + ForkedLifecycle { event_ids: Vec<String> }, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -120,6 +168,14 @@ pub struct RadrootsActiveOrderProjection { pub decision_event_id: Option<String>, pub fulfillment_event_id: Option<String>, pub fulfillment_status: Option<RadrootsActiveTradeFulfillmentState>, + pub cancellation_event_id: Option<String>, + pub receipt_event_id: Option<String>, + pub receipt_received: Option<bool>, + pub receipt_issue: Option<String>, + pub receipt_received_at: Option<u64>, + pub lifecycle_terminal: bool, + pub settlement_pending: bool, + pub settlement_reason: Option<String>, pub listing_addr: Option<String>, pub buyer_pubkey: Option<String>, pub seller_pubkey: Option<String>, @@ -178,24 +234,37 @@ pub struct RadrootsListingInventoryAccountingProjection { pub listing_event_id: String, pub bins: Vec<RadrootsListingInventoryBinAccounting>, pub declined_order_ids: Vec<String>, + pub cancelled_order_ids: Vec<String>, pub invalid_event_ids: Vec<String>, pub issues: Vec<RadrootsListingInventoryAccountingIssue>, } -pub fn reduce_active_order_events<I, J>( +pub fn reduce_active_order_events<I, J, K, L, M>( order_id: &str, requests: I, decisions: J, - fulfillments: impl IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + fulfillments: K, + cancellations: L, + receipts: M, ) -> RadrootsActiveOrderProjection where I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, J: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, + K: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + L: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, + M: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, { let requests = unique_request_records(requests); let decisions = unique_decision_records(decisions); let fulfillments = unique_fulfillment_records(fulfillments); - if requests.is_empty() && decisions.is_empty() && fulfillments.is_empty() { + let cancellations = unique_cancellation_records(cancellations); + let receipts = unique_receipt_records(receipts); + if requests.is_empty() + && decisions.is_empty() + && fulfillments.is_empty() + && cancellations.is_empty() + && receipts.is_empty() + { return RadrootsActiveOrderProjection { order_id: order_id.to_string(), status: RadrootsActiveOrderStatus::Missing, @@ -203,6 +272,14 @@ where decision_event_id: None, fulfillment_event_id: None, fulfillment_status: None, + cancellation_event_id: None, + receipt_event_id: None, + receipt_received: None, + receipt_issue: None, + receipt_received_at: None, + lifecycle_terminal: false, + settlement_pending: false, + settlement_reason: None, listing_addr: None, buyer_pubkey: None, seller_pubkey: None, @@ -229,7 +306,11 @@ where } let Some(request) = valid_requests.first() else { - if decisions.is_empty() && fulfillments.is_empty() { + if decisions.is_empty() + && fulfillments.is_empty() + && cancellations.is_empty() + && receipts.is_empty() + { return invalid_projection(order_id, None, issues); } issues.push(RadrootsActiveOrderReducerIssue::MissingRequest); @@ -251,16 +332,68 @@ where return invalid_projection(order_id, Some(request), issues); } + let mut valid_cancellations = Vec::new(); + for cancellation in cancellations { + if validate_active_cancellation_record(request, &cancellation, &mut issues) { + valid_cancellations.push(cancellation); + } + } + let mut valid_receipts = Vec::new(); + for receipt in receipts { + if validate_active_receipt_record(request, &receipt, &mut issues) { + valid_receipts.push(receipt); + } + } + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + + let request_cancellations = valid_cancellations + .iter() + .filter(|cancellation| cancellation.prev_event_id == request.event_id) + .collect::<Vec<_>>(); + if !request_cancellations.is_empty() && !valid_decisions.is_empty() { + let mut event_ids = valid_decisions + .iter() + .map(|decision| decision.event_id.clone()) + .collect::<Vec<_>>(); + event_ids.extend( + request_cancellations + .iter() + .map(|cancellation| cancellation.event_id.clone()), + ); + sort_and_dedup_strings(&mut event_ids); + return invalid_projection( + order_id, + Some(request), + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }], + ); + } + match valid_decisions.len() { 0 => { - if fulfillments.is_empty() { - requested_projection(order_id, request) - } else { + if !fulfillments.is_empty() { record_fulfillment_without_accepted_decision(&fulfillments, &mut issues); + } + if !valid_receipts.is_empty() { + record_receipt_without_eligible_fulfillment(&valid_receipts, &mut issues); + } + if !issues.is_empty() { invalid_projection(order_id, Some(request), issues) + } else if valid_cancellations.is_empty() { + requested_projection(order_id, request) + } else { + requested_cancellation_projection(order_id, request, valid_cancellations) } } - 1 => decided_projection(order_id, request, &valid_decisions[0], fulfillments), + 1 => decided_projection( + order_id, + request, + &valid_decisions[0], + fulfillments, + valid_cancellations, + valid_receipts, + ), _ => { let mut event_ids = valid_decisions .iter() @@ -276,19 +409,23 @@ where } } -pub fn reduce_listing_inventory_accounting<I, J, K, L>( +pub fn reduce_listing_inventory_accounting<I, J, K, L, M, N>( listing_addr: &str, listing_event_id: &str, bins: I, requests: J, decisions: K, fulfillments: L, + cancellations: M, + receipts: N, ) -> RadrootsListingInventoryAccountingProjection where I: IntoIterator<Item = RadrootsListingInventoryBinAvailability>, J: IntoIterator<Item = RadrootsActiveOrderRequestRecord>, K: IntoIterator<Item = RadrootsActiveOrderDecisionRecord>, L: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>, + M: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, + N: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, { let (mut bins, mut issues) = normalized_listing_inventory_bins(bins); let requests = unique_request_records(requests) @@ -303,8 +440,23 @@ where .into_iter() .filter(|fulfillment| fulfillment.payload.listing_addr.trim() == listing_addr) .collect::<Vec<_>>(); - let mut order_ids = listing_order_ids(&requests, &decisions, &fulfillments); + let cancellations = unique_cancellation_records(cancellations) + .into_iter() + .filter(|cancellation| cancellation.payload.listing_addr.trim() == listing_addr) + .collect::<Vec<_>>(); + let receipts = unique_receipt_records(receipts) + .into_iter() + .filter(|receipt| receipt.payload.listing_addr.trim() == listing_addr) + .collect::<Vec<_>>(); + let mut order_ids = listing_order_ids( + &requests, + &decisions, + &fulfillments, + &cancellations, + &receipts, + ); let mut declined_order_ids = Vec::new(); + let mut cancelled_order_ids = Vec::new(); let mut invalid_event_ids = Vec::new(); for order_id in order_ids.drain(..) { @@ -323,14 +475,28 @@ where .filter(|fulfillment| fulfillment.payload.order_id == order_id) .cloned() .collect::<Vec<_>>(); + let order_cancellations = cancellations + .iter() + .filter(|cancellation| cancellation.payload.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); + let order_receipts = receipts + .iter() + .filter(|receipt| receipt.payload.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); let projection = reduce_active_order_events( &order_id, order_requests.clone(), order_decisions.clone(), order_fulfillments.clone(), + order_cancellations.clone(), + order_receipts.clone(), ); match projection.status { - RadrootsActiveOrderStatus::Accepted => { + RadrootsActiveOrderStatus::Accepted + | RadrootsActiveOrderStatus::Completed + | RadrootsActiveOrderStatus::Disputed => { if projection.fulfillment_status == Some(RadrootsActiveTradeFulfillmentState::SellerCancelled) { @@ -349,6 +515,7 @@ where ); } } + RadrootsActiveOrderStatus::Cancelled => cancelled_order_ids.push(order_id), RadrootsActiveOrderStatus::Declined => declined_order_ids.push(order_id), RadrootsActiveOrderStatus::Invalid => { let mut event_ids = projection_issue_event_ids(&projection.issues); @@ -363,6 +530,21 @@ where .iter() .map(|decision| decision.event_id.clone()), ); + event_ids.extend( + order_fulfillments + .iter() + .map(|fulfillment| fulfillment.event_id.clone()), + ); + event_ids.extend( + order_cancellations + .iter() + .map(|cancellation| cancellation.event_id.clone()), + ); + event_ids.extend( + order_receipts + .iter() + .map(|receipt| receipt.event_id.clone()), + ); sort_and_dedup_strings(&mut event_ids); } invalid_event_ids.extend(event_ids.iter().cloned()); @@ -378,6 +560,7 @@ where } sort_and_dedup_strings(&mut declined_order_ids); + sort_and_dedup_strings(&mut cancelled_order_ids); sort_and_dedup_strings(&mut invalid_event_ids); finish_inventory_accounting_bins(&mut bins, &mut issues); issues.sort_by(inventory_issue_sort_key); @@ -386,6 +569,7 @@ where listing_event_id: listing_event_id.to_string(), bins, declined_order_ids, + cancelled_order_ids, invalid_event_ids, issues, } @@ -575,6 +759,46 @@ where unique } +fn unique_cancellation_records<I>(cancellations: I) -> Vec<RadrootsActiveOrderCancellationRecord> +where + I: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>, +{ + let mut unique = Vec::new(); + let mut records = cancellations.into_iter().collect::<Vec<_>>(); + records.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + for cancellation in records { + if unique + .iter() + .all(|existing: &RadrootsActiveOrderCancellationRecord| { + existing.event_id != cancellation.event_id + }) + { + unique.push(cancellation); + } + } + unique +} + +fn unique_receipt_records<I>(receipts: I) -> Vec<RadrootsActiveOrderReceiptRecord> +where + I: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>, +{ + let mut unique = Vec::new(); + let mut records = receipts.into_iter().collect::<Vec<_>>(); + records.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + for receipt in records { + if unique + .iter() + .all(|existing: &RadrootsActiveOrderReceiptRecord| { + existing.event_id != receipt.event_id + }) + { + unique.push(receipt); + } + } + unique +} + fn normalized_listing_inventory_bins<I>( bins: I, ) -> ( @@ -627,6 +851,8 @@ fn listing_order_ids( requests: &[RadrootsActiveOrderRequestRecord], decisions: &[RadrootsActiveOrderDecisionRecord], fulfillments: &[RadrootsActiveOrderFulfillmentRecord], + cancellations: &[RadrootsActiveOrderCancellationRecord], + receipts: &[RadrootsActiveOrderReceiptRecord], ) -> Vec<String> { let mut order_ids = Vec::new(); order_ids.extend( @@ -644,6 +870,16 @@ fn listing_order_ids( .iter() .map(|fulfillment| fulfillment.payload.order_id.clone()), ); + order_ids.extend( + cancellations + .iter() + .map(|cancellation| cancellation.payload.order_id.clone()), + ); + order_ids.extend( + receipts + .iter() + .map(|receipt| receipt.payload.order_id.clone()), + ); sort_and_dedup_strings(&mut order_ids); order_ids } @@ -746,7 +982,8 @@ fn projection_issue_event_ids(issues: &[RadrootsActiveOrderReducerIssue]) -> Vec match issue { RadrootsActiveOrderReducerIssue::MissingRequest => {} RadrootsActiveOrderReducerIssue::MultipleRequests { event_ids: ids } - | RadrootsActiveOrderReducerIssue::ConflictingDecisions { event_ids: ids } => { + | RadrootsActiveOrderReducerIssue::ConflictingDecisions { event_ids: ids } + | RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids: ids } => { event_ids.extend(ids.iter().cloned()); } RadrootsActiveOrderReducerIssue::RequestPayloadInvalid { event_id } @@ -779,7 +1016,30 @@ fn projection_issue_event_ids(issues: &[RadrootsActiveOrderReducerIssue]) -> Vec | RadrootsActiveOrderReducerIssue::FulfillmentRootMismatch { event_id } | RadrootsActiveOrderReducerIssue::FulfillmentPreviousMismatch { event_id } | RadrootsActiveOrderReducerIssue::FulfillmentStatusNotPublishable { event_id } - | RadrootsActiveOrderReducerIssue::FulfillmentUnsupportedTransition { event_id } => { + | RadrootsActiveOrderReducerIssue::FulfillmentUnsupportedTransition { event_id } + | RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { event_id } + | RadrootsActiveOrderReducerIssue::CancellationPayloadInvalid { event_id } + | RadrootsActiveOrderReducerIssue::CancellationOrderIdMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationAuthorMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationCounterpartyMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationBuyerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationSellerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationListingAddressInvalid { event_id } + | RadrootsActiveOrderReducerIssue::CancellationListingMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationRootMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { event_id } + | RadrootsActiveOrderReducerIssue::CancellationAfterFulfillment { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptWithoutEligibleFulfillment { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptPayloadInvalid { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptOrderIdMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptAuthorMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptCounterpartyMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptBuyerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptSellerMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptListingAddressInvalid { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptListingMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptRootMismatch { event_id } + | RadrootsActiveOrderReducerIssue::ReceiptPreviousMismatch { event_id } => { event_ids.push(event_id.clone()); } RadrootsActiveOrderReducerIssue::ForkedFulfillments { event_ids: ids } => { @@ -1070,6 +1330,178 @@ fn validate_active_fulfillment_record( valid } +fn validate_active_cancellation_record( + request: &RadrootsActiveOrderRequestRecord, + cancellation: &RadrootsActiveOrderCancellationRecord, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + let mut valid = true; + if cancellation.payload.validate().is_err() { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationPayloadInvalid { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + if cancellation.payload.order_id != request.payload.order_id { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationOrderIdMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + if cancellation.author_pubkey != cancellation.payload.buyer_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationAuthorMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + if cancellation.counterparty_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationCounterpartyMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + if cancellation.payload.buyer_pubkey != request.payload.buyer_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::CancellationBuyerMismatch { + event_id: cancellation.event_id.clone(), + }); + valid = false; + } + if cancellation.payload.seller_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationSellerMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + match parse_public_listing_addr(&cancellation.payload.listing_addr) { + Ok(listing_addr) => { + if cancellation.payload.listing_addr != request.payload.listing_addr + || listing_addr.seller_pubkey != cancellation.payload.seller_pubkey + { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationListingMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + } + Err(_) => { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationListingAddressInvalid { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + } + if cancellation.root_event_id != request.event_id { + issues.push(RadrootsActiveOrderReducerIssue::CancellationRootMismatch { + event_id: cancellation.event_id.clone(), + }); + valid = false; + } + if cancellation.prev_event_id.trim().is_empty() + || cancellation.prev_event_id == cancellation.event_id + { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + valid = false; + } + valid +} + +fn validate_active_receipt_record( + request: &RadrootsActiveOrderRequestRecord, + receipt: &RadrootsActiveOrderReceiptRecord, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> bool { + let mut valid = true; + if receipt.payload.validate().is_err() { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptPayloadInvalid { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if receipt.payload.order_id != request.payload.order_id { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptOrderIdMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if receipt.author_pubkey != receipt.payload.buyer_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptAuthorMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if receipt.counterparty_pubkey != request.payload.seller_pubkey { + issues.push( + RadrootsActiveOrderReducerIssue::ReceiptCounterpartyMismatch { + event_id: receipt.event_id.clone(), + }, + ); + valid = false; + } + if receipt.payload.buyer_pubkey != request.payload.buyer_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptBuyerMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if receipt.payload.seller_pubkey != request.payload.seller_pubkey { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptSellerMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + match parse_public_listing_addr(&receipt.payload.listing_addr) { + Ok(listing_addr) => { + if receipt.payload.listing_addr != request.payload.listing_addr + || listing_addr.seller_pubkey != receipt.payload.seller_pubkey + { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptListingMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + } + Err(_) => { + issues.push( + RadrootsActiveOrderReducerIssue::ReceiptListingAddressInvalid { + event_id: receipt.event_id.clone(), + }, + ); + valid = false; + } + } + if receipt.root_event_id != request.event_id { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptRootMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + if receipt.prev_event_id.trim().is_empty() || receipt.prev_event_id == receipt.event_id { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptPreviousMismatch { + event_id: receipt.event_id.clone(), + }); + valid = false; + } + valid +} + fn decision_payload_issue( decision: &RadrootsTradeOrderDecision, event_id: &str, @@ -1116,18 +1548,69 @@ fn record_fulfillment_without_accepted_decision( } } -fn latest_fulfillment_record( +fn record_cancellation_without_cancellable_order( + cancellations: &[RadrootsActiveOrderCancellationRecord], + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) { + for cancellation in cancellations { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { + event_id: cancellation.event_id.clone(), + }, + ); + } +} + +fn record_receipt_without_eligible_fulfillment( + receipts: &[RadrootsActiveOrderReceiptRecord], + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) { + for receipt in receipts { + issues.push( + RadrootsActiveOrderReducerIssue::ReceiptWithoutEligibleFulfillment { + event_id: receipt.event_id.clone(), + }, + ); + } +} + +fn single_lifecycle_child<T>( + records: &[T], + event_id: impl Fn(&T) -> &String, +) -> Result<Option<T>, RadrootsActiveOrderReducerIssue> +where + T: Clone, +{ + match records { + [] => Ok(None), + [record] => Ok(Some(record.clone())), + _ => { + let mut event_ids = records.iter().map(event_id).cloned().collect::<Vec<_>>(); + event_ids.sort(); + Err(RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }) + } + } +} + +fn validated_fulfillment_records( request: &RadrootsActiveOrderRequestRecord, - decision: &RadrootsActiveOrderDecisionRecord, fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>, issues: &mut Vec<RadrootsActiveOrderReducerIssue>, -) -> Option<RadrootsActiveOrderFulfillmentRecord> { +) -> Vec<RadrootsActiveOrderFulfillmentRecord> { let mut valid_fulfillments = Vec::new(); for fulfillment in fulfillments { if validate_active_fulfillment_record(request, &fulfillment, issues) { valid_fulfillments.push(fulfillment); } } + valid_fulfillments +} + +fn latest_fulfillment_record( + decision: &RadrootsActiveOrderDecisionRecord, + valid_fulfillments: &[RadrootsActiveOrderFulfillmentRecord], + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> Option<RadrootsActiveOrderFulfillmentRecord> { if !issues.is_empty() { return None; } @@ -1180,7 +1663,7 @@ fn latest_fulfillment_record( if !used_event_ids.contains(&fulfillment.event_id) { issues.push( RadrootsActiveOrderReducerIssue::FulfillmentPreviousMismatch { - event_id: fulfillment.event_id, + event_id: fulfillment.event_id.clone(), }, ); } @@ -1199,6 +1682,14 @@ fn requested_projection( decision_event_id: None, fulfillment_event_id: None, fulfillment_status: None, + cancellation_event_id: None, + receipt_event_id: None, + receipt_received: None, + receipt_issue: None, + receipt_received_at: None, + lifecycle_terminal: false, + settlement_pending: false, + settlement_reason: None, listing_addr: Some(request.payload.listing_addr.clone()), buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), seller_pubkey: Some(request.payload.seller_pubkey.clone()), @@ -1207,11 +1698,43 @@ fn requested_projection( } } +fn requested_cancellation_projection( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, + cancellations: Vec<RadrootsActiveOrderCancellationRecord>, +) -> RadrootsActiveOrderProjection { + let mut issues = Vec::new(); + for cancellation in cancellations + .iter() + .filter(|cancellation| cancellation.prev_event_id != request.event_id) + { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + } + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + let matching = cancellations + .into_iter() + .filter(|cancellation| cancellation.prev_event_id == request.event_id) + .collect::<Vec<_>>(); + match single_lifecycle_child(&matching, |record| &record.event_id) { + Ok(Some(cancellation)) => cancelled_projection(order_id, request, None, cancellation), + Ok(None) => requested_projection(order_id, request), + Err(issue) => invalid_projection(order_id, Some(request), vec![issue]), + } +} + fn decided_projection( order_id: &str, request: &RadrootsActiveOrderRequestRecord, decision: &RadrootsActiveOrderDecisionRecord, fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>, + cancellations: Vec<RadrootsActiveOrderCancellationRecord>, + receipts: Vec<RadrootsActiveOrderReceiptRecord>, ) -> RadrootsActiveOrderProjection { let status = match &decision.payload.decision { RadrootsTradeOrderDecision::Accepted { .. } => RadrootsActiveOrderStatus::Accepted, @@ -1220,17 +1743,94 @@ fn decided_projection( let mut issues = Vec::new(); let (fulfillment_event_id, fulfillment_status, last_event_id) = match status { RadrootsActiveOrderStatus::Accepted => { - let latest = latest_fulfillment_record(request, decision, fulfillments, &mut issues); + let fulfillment_records = + validated_fulfillment_records(request, fulfillments, &mut issues); + let latest = latest_fulfillment_record(decision, &fulfillment_records, &mut issues); if !issues.is_empty() { return invalid_projection(order_id, Some(request), issues); } - match latest { - Some(fulfillment) => ( - Some(fulfillment.event_id.clone()), - Some(fulfillment.payload.status), - Some(fulfillment.event_id), - ), - None => ( + let decision_cancellations = cancellations + .iter() + .cloned() + .filter(|cancellation| cancellation.prev_event_id == decision.event_id) + .collect::<Vec<_>>(); + for cancellation in cancellations + .iter() + .filter(|cancellation| cancellation.prev_event_id != decision.event_id) + { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { + event_id: cancellation.event_id.clone(), + }, + ); + } + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + if let Some(first_fulfillment) = fulfillment_records + .iter() + .find(|fulfillment| fulfillment.prev_event_id == decision.event_id) + && !decision_cancellations.is_empty() + { + let mut event_ids = decision_cancellations + .iter() + .map(|cancellation| cancellation.event_id.clone()) + .collect::<Vec<_>>(); + event_ids.push(first_fulfillment.event_id.clone()); + sort_and_dedup_strings(&mut event_ids); + return invalid_projection( + order_id, + Some(request), + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }], + ); + } + if latest.is_some() { + for cancellation in decision_cancellations { + issues.push( + RadrootsActiveOrderReducerIssue::CancellationAfterFulfillment { + event_id: cancellation.event_id, + }, + ); + } + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + } else { + match single_lifecycle_child(&decision_cancellations, |record| &record.event_id) { + Ok(Some(cancellation)) => { + return cancelled_projection( + order_id, + request, + Some(decision.event_id.clone()), + cancellation, + ); + } + Ok(None) => {} + Err(issue) => return invalid_projection(order_id, Some(request), vec![issue]), + } + } + let receipt_result = receipt_projection( + order_id, + request, + decision, + latest.as_ref(), + &fulfillment_records, + receipts, + &mut issues, + ); + if let Some(projection) = receipt_result { + return projection; + } + if !issues.is_empty() { + return invalid_projection(order_id, Some(request), issues); + } + match latest { + Some(fulfillment) => ( + Some(fulfillment.event_id.clone()), + Some(fulfillment.payload.status), + Some(fulfillment.event_id), + ), + None => ( None, Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled), Some(decision.event_id.clone()), @@ -1238,10 +1838,12 @@ fn decided_projection( } } RadrootsActiveOrderStatus::Declined => { - if fulfillments.is_empty() { + if fulfillments.is_empty() && cancellations.is_empty() && receipts.is_empty() { (None, None, Some(decision.event_id.clone())) } else { record_fulfillment_without_accepted_decision(&fulfillments, &mut issues); + record_cancellation_without_cancellable_order(&cancellations, &mut issues); + record_receipt_without_eligible_fulfillment(&receipts, &mut issues); return invalid_projection(order_id, Some(request), issues); } } @@ -1254,6 +1856,14 @@ fn decided_projection( decision_event_id: Some(decision.event_id.clone()), fulfillment_event_id, fulfillment_status, + cancellation_event_id: None, + receipt_event_id: None, + receipt_received: None, + receipt_issue: None, + receipt_received_at: None, + lifecycle_terminal: false, + settlement_pending: false, + settlement_reason: None, listing_addr: Some(request.payload.listing_addr.clone()), buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), seller_pubkey: Some(request.payload.seller_pubkey.clone()), @@ -1262,6 +1872,154 @@ fn decided_projection( } } +fn receipt_projection( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, + decision: &RadrootsActiveOrderDecisionRecord, + latest_fulfillment: Option<&RadrootsActiveOrderFulfillmentRecord>, + fulfillments: &[RadrootsActiveOrderFulfillmentRecord], + receipts: Vec<RadrootsActiveOrderReceiptRecord>, + issues: &mut Vec<RadrootsActiveOrderReducerIssue>, +) -> Option<RadrootsActiveOrderProjection> { + if receipts.is_empty() { + return None; + } + let Some(fulfillment) = latest_fulfillment else { + record_receipt_without_eligible_fulfillment(&receipts, issues); + return None; + }; + if !matches!( + fulfillment.payload.status, + RadrootsActiveTradeFulfillmentState::ReadyForPickup + | RadrootsActiveTradeFulfillmentState::Delivered + ) { + record_receipt_without_eligible_fulfillment(&receipts, issues); + return None; + } + let mut fork_event_ids = Vec::new(); + for receipt in &receipts { + let Some(receipt_parent) = fulfillments + .iter() + .find(|candidate| candidate.event_id == receipt.prev_event_id) + else { + continue; + }; + if !matches!( + receipt_parent.payload.status, + RadrootsActiveTradeFulfillmentState::ReadyForPickup + | RadrootsActiveTradeFulfillmentState::Delivered + ) { + continue; + } + let sibling_fulfillment_event_ids = fulfillments + .iter() + .filter(|candidate| candidate.prev_event_id == receipt.prev_event_id) + .map(|candidate| candidate.event_id.clone()) + .collect::<Vec<_>>(); + if !sibling_fulfillment_event_ids.is_empty() { + fork_event_ids.push(receipt.event_id.clone()); + fork_event_ids.extend(sibling_fulfillment_event_ids); + } + } + if !fork_event_ids.is_empty() { + sort_and_dedup_strings(&mut fork_event_ids); + issues.push(RadrootsActiveOrderReducerIssue::ForkedLifecycle { + event_ids: fork_event_ids, + }); + return None; + } + let matching = receipts + .iter() + .cloned() + .filter(|receipt| receipt.prev_event_id == fulfillment.event_id) + .collect::<Vec<_>>(); + match single_lifecycle_child(&matching, |record| &record.event_id) { + Ok(Some(receipt)) => Some(receipt_terminal_projection( + order_id, + request, + decision, + fulfillment, + receipt, + )), + Ok(None) => { + for receipt in receipts { + issues.push(RadrootsActiveOrderReducerIssue::ReceiptPreviousMismatch { + event_id: receipt.event_id, + }); + } + None + } + Err(issue) => { + issues.push(issue); + None + } + } +} + +fn cancelled_projection( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, + decision_event_id: Option<String>, + cancellation: RadrootsActiveOrderCancellationRecord, +) -> RadrootsActiveOrderProjection { + RadrootsActiveOrderProjection { + order_id: order_id.to_string(), + status: RadrootsActiveOrderStatus::Cancelled, + request_event_id: Some(request.event_id.clone()), + decision_event_id, + fulfillment_event_id: None, + fulfillment_status: None, + cancellation_event_id: Some(cancellation.event_id.clone()), + receipt_event_id: None, + receipt_received: None, + receipt_issue: None, + receipt_received_at: None, + lifecycle_terminal: true, + settlement_pending: true, + settlement_reason: Some(cancellation.payload.reason), + listing_addr: Some(request.payload.listing_addr.clone()), + buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), + seller_pubkey: Some(request.payload.seller_pubkey.clone()), + last_event_id: Some(cancellation.event_id), + issues: Vec::new(), + } +} + +fn receipt_terminal_projection( + order_id: &str, + request: &RadrootsActiveOrderRequestRecord, + decision: &RadrootsActiveOrderDecisionRecord, + fulfillment: &RadrootsActiveOrderFulfillmentRecord, + receipt: RadrootsActiveOrderReceiptRecord, +) -> RadrootsActiveOrderProjection { + let status = if receipt.payload.received { + RadrootsActiveOrderStatus::Completed + } else { + RadrootsActiveOrderStatus::Disputed + }; + RadrootsActiveOrderProjection { + order_id: order_id.to_string(), + status, + request_event_id: Some(request.event_id.clone()), + decision_event_id: Some(decision.event_id.clone()), + fulfillment_event_id: Some(fulfillment.event_id.clone()), + fulfillment_status: Some(fulfillment.payload.status), + cancellation_event_id: None, + receipt_event_id: Some(receipt.event_id.clone()), + receipt_received: Some(receipt.payload.received), + receipt_issue: receipt.payload.issue.clone(), + receipt_received_at: Some(receipt.payload.received_at), + lifecycle_terminal: true, + settlement_pending: !receipt.payload.received, + settlement_reason: receipt.payload.issue, + listing_addr: Some(request.payload.listing_addr.clone()), + buyer_pubkey: Some(request.payload.buyer_pubkey.clone()), + seller_pubkey: Some(request.payload.seller_pubkey.clone()), + last_event_id: Some(receipt.event_id), + issues: Vec::new(), + } +} + fn invalid_projection( order_id: &str, request: Option<&RadrootsActiveOrderRequestRecord>, @@ -1274,6 +2032,14 @@ fn invalid_projection( decision_event_id: None, fulfillment_event_id: None, fulfillment_status: None, + cancellation_event_id: None, + receipt_event_id: None, + receipt_received: None, + receipt_issue: None, + receipt_received_at: None, + lifecycle_terminal: true, + settlement_pending: false, + settlement_reason: None, listing_addr: request.map(|request| request.payload.listing_addr.clone()), buyer_pubkey: request.map(|request| request.payload.buyer_pubkey.clone()), seller_pubkey: request.map(|request| request.payload.seller_pubkey.clone()), @@ -1411,14 +2177,15 @@ fn normalized_required_string( mod tests { use radroots_events::kinds::KIND_LISTING; use radroots_events::trade::{ - RadrootsActiveTradeFulfillmentState, RadrootsTradeFulfillmentUpdated, - RadrootsTradeInventoryCommitment, RadrootsTradeOrder as TradeOrder, - RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, + RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, + RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, + RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, + RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; use super::{ - RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderFulfillmentRecord, + RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, + RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, RadrootsActiveOrderStatus, RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryBinAccounting, RadrootsListingInventoryBinAvailability, @@ -1573,6 +2340,49 @@ mod tests { } } + fn cancellation_record( + event_id: &str, + prev_event_id: &str, + ) -> RadrootsActiveOrderCancellationRecord { + RadrootsActiveOrderCancellationRecord { + event_id: event_id.to_string(), + author_pubkey: BUYER.to_string(), + counterparty_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: prev_event_id.to_string(), + payload: RadrootsTradeOrderCancelled { + order_id: "order-1".to_string(), + listing_addr: listing_addr(), + buyer_pubkey: BUYER.to_string(), + seller_pubkey: SELLER.to_string(), + reason: "changed plans".to_string(), + }, + } + } + + fn receipt_record( + event_id: &str, + prev_event_id: &str, + received: bool, + ) -> RadrootsActiveOrderReceiptRecord { + RadrootsActiveOrderReceiptRecord { + event_id: event_id.to_string(), + author_pubkey: BUYER.to_string(), + counterparty_pubkey: SELLER.to_string(), + root_event_id: "request-1".to_string(), + prev_event_id: prev_event_id.to_string(), + payload: RadrootsTradeBuyerReceipt { + order_id: "order-1".to_string(), + listing_addr: listing_addr(), + buyer_pubkey: BUYER.to_string(), + seller_pubkey: SELLER.to_string(), + received, + issue: (!received).then(|| "damaged items".to_string()), + received_at: 1_777_665_600, + }, + } + } + fn accepted_decision_record_for( order_id: &str, event_id: &str, @@ -1701,7 +2511,7 @@ mod tests { #[test] fn reduce_active_order_events_reports_missing_without_events() { - let projection = reduce_active_order_events("order-1", [], [], []); + let projection = reduce_active_order_events("order-1", [], [], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Missing); assert!(projection.issues.is_empty()); @@ -1709,7 +2519,7 @@ mod tests { #[test] fn reduce_active_order_events_reports_requested_state() { - let projection = reduce_active_order_events("order-1", [request_record()], [], []); + let projection = reduce_active_order_events("order-1", [request_record()], [], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Requested); assert_eq!(projection.request_event_id.as_deref(), Some("request-1")); @@ -1723,6 +2533,8 @@ mod tests { [request_record()], [accepted_decision_record("decision-1")], [], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); @@ -1753,6 +2565,8 @@ mod tests { RadrootsActiveTradeFulfillmentState::Preparing, ), ], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); @@ -1768,6 +2582,233 @@ mod tests { } #[test] + fn reduce_active_order_events_keeps_delivered_without_receipt_nonterminal() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::Delivered, + )], + [], + [], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); + assert_eq!( + projection.fulfillment_status, + Some(RadrootsActiveTradeFulfillmentState::Delivered) + ); + assert!(!projection.lifecycle_terminal); + } + + #[test] + fn reduce_active_order_events_reports_requested_cancellation() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [], + [], + [cancellation_record("cancel-1", "request-1")], + [], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Cancelled); + assert_eq!(projection.request_event_id.as_deref(), Some("request-1")); + assert_eq!( + projection.cancellation_event_id.as_deref(), + Some("cancel-1") + ); + assert_eq!(projection.last_event_id.as_deref(), Some("cancel-1")); + assert!(projection.lifecycle_terminal); + assert!(projection.settlement_pending); + assert_eq!( + projection.settlement_reason.as_deref(), + Some("changed plans") + ); + assert!(projection.issues.is_empty()); + } + + #[test] + fn reduce_active_order_events_rejects_request_cancellation_decision_fork() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [], + [cancellation_record("cancel-1", "request-1")], + [], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert_eq!( + projection.issues, + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { + event_ids: vec!["cancel-1".to_string(), "decision-1".to_string()] + }] + ); + } + + #[test] + fn reduce_active_order_events_reports_accepted_cancellation_before_fulfillment() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [], + [cancellation_record("cancel-1", "decision-1")], + [], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Cancelled); + assert_eq!(projection.decision_event_id.as_deref(), Some("decision-1")); + assert_eq!( + projection.cancellation_event_id.as_deref(), + Some("cancel-1") + ); + assert_eq!(projection.last_event_id.as_deref(), Some("cancel-1")); + assert!(projection.lifecycle_terminal); + } + + #[test] + fn reduce_active_order_events_rejects_cancellation_fulfillment_fork() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::Preparing, + )], + [cancellation_record("cancel-1", "decision-1")], + [], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert_eq!( + projection.issues, + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { + event_ids: vec!["cancel-1".to_string(), "fulfillment-1".to_string()] + }] + ); + } + + #[test] + fn reduce_active_order_events_reports_completed_buyer_receipt() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + )], + [], + [receipt_record("receipt-1", "fulfillment-1", true)], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Completed); + assert_eq!( + projection.fulfillment_event_id.as_deref(), + Some("fulfillment-1") + ); + assert_eq!(projection.receipt_event_id.as_deref(), Some("receipt-1")); + assert_eq!(projection.receipt_received, Some(true)); + assert_eq!(projection.receipt_issue, None); + assert_eq!(projection.receipt_received_at, Some(1_777_665_600)); + assert!(projection.lifecycle_terminal); + assert!(!projection.settlement_pending); + } + + #[test] + fn reduce_active_order_events_rejects_receipt_fulfillment_fork() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [ + fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ), + fulfillment_record( + "fulfillment-2", + "fulfillment-1", + RadrootsActiveTradeFulfillmentState::Delivered, + ), + ], + [], + [receipt_record("receipt-1", "fulfillment-1", true)], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert_eq!( + projection.issues, + vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { + event_ids: vec!["fulfillment-2".to_string(), "receipt-1".to_string()] + }] + ); + } + + #[test] + fn reduce_active_order_events_reports_disputed_buyer_receipt() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::Delivered, + )], + [], + [receipt_record("receipt-1", "fulfillment-1", false)], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Disputed); + assert_eq!(projection.receipt_event_id.as_deref(), Some("receipt-1")); + assert_eq!(projection.receipt_received, Some(false)); + assert_eq!(projection.receipt_issue.as_deref(), Some("damaged items")); + assert!(projection.lifecycle_terminal); + assert!(projection.settlement_pending); + assert_eq!( + projection.settlement_reason.as_deref(), + Some("damaged items") + ); + } + + #[test] + fn reduce_active_order_events_rejects_receipt_without_eligible_fulfillment() { + let projection = reduce_active_order_events( + "order-1", + [request_record()], + [accepted_decision_record("decision-1")], + [fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::Preparing, + )], + [], + [receipt_record("receipt-1", "fulfillment-1", true)], + ); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert_eq!( + projection.issues, + vec![ + RadrootsActiveOrderReducerIssue::ReceiptWithoutEligibleFulfillment { + event_id: "receipt-1".to_string() + } + ] + ); + } + + #[test] fn reduce_active_order_events_rejects_fulfillment_before_acceptance() { let projection = reduce_active_order_events( "order-1", @@ -1778,6 +2819,8 @@ mod tests { "request-1", RadrootsActiveTradeFulfillmentState::Preparing, )], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); @@ -1802,6 +2845,8 @@ mod tests { "decision-1", RadrootsActiveTradeFulfillmentState::Preparing, )], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); @@ -1829,6 +2874,8 @@ mod tests { [request_record()], [accepted_decision_record("decision-1")], [fulfillment], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); @@ -1857,6 +2904,8 @@ mod tests { RadrootsActiveTradeFulfillmentState::ReadyForPickup, ), ], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); @@ -1886,6 +2935,8 @@ mod tests { RadrootsActiveTradeFulfillmentState::ReadyForPickup, ), ], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); @@ -1906,6 +2957,8 @@ mod tests { [request_record()], [declined_decision_record("decision-1")], [], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Declined); @@ -1921,10 +2974,13 @@ mod tests { [request_record()], [accepted_decision_record("decision-1")], [], + [], + [], ); assert_eq!(projection.listing_event_id, "listing-event-1"); assert_eq!(projection.declined_order_ids, Vec::<String>::new()); + assert_eq!(projection.cancelled_order_ids, Vec::<String>::new()); assert_eq!(projection.invalid_event_ids, Vec::<String>::new()); assert!(projection.issues.is_empty()); assert_eq!( @@ -1957,6 +3013,8 @@ mod tests { "decision-1", RadrootsActiveTradeFulfillmentState::SellerCancelled, )], + [], + [], ); assert!(projection.issues.is_empty()); @@ -1967,6 +3025,50 @@ mod tests { } #[test] + fn reduce_listing_inventory_accounting_releases_accepted_buyer_cancelled_order() { + let projection = reduce_listing_inventory_accounting( + &listing_addr(), + "listing-event-1", + [inventory_bin(5)], + [request_record()], + [accepted_decision_record("decision-1")], + [], + [cancellation_record("cancel-1", "decision-1")], + [], + ); + + assert!(projection.issues.is_empty()); + assert_eq!(projection.cancelled_order_ids, vec!["order-1".to_string()]); + assert_eq!(projection.invalid_event_ids, Vec::<String>::new()); + assert_eq!(projection.bins[0].accepted_reserved_count, 0); + assert_eq!(projection.bins[0].remaining_count, 5); + assert!(projection.bins[0].accepted_orders.is_empty()); + } + + #[test] + fn reduce_listing_inventory_accounting_keeps_receipted_order_reserved() { + let projection = reduce_listing_inventory_accounting( + &listing_addr(), + "listing-event-1", + [inventory_bin(5)], + [request_record()], + [accepted_decision_record("decision-1")], + [fulfillment_record( + "fulfillment-1", + "decision-1", + RadrootsActiveTradeFulfillmentState::Delivered, + )], + [], + [receipt_record("receipt-1", "fulfillment-1", true)], + ); + + assert!(projection.issues.is_empty()); + assert!(projection.cancelled_order_ids.is_empty()); + assert_eq!(projection.bins[0].accepted_reserved_count, 2); + assert_eq!(projection.bins[0].remaining_count, 3); + } + + #[test] fn reduce_listing_inventory_accounting_rejects_forked_cancel_release() { let projection = reduce_listing_inventory_accounting( &listing_addr(), @@ -1986,6 +3088,8 @@ mod tests { RadrootsActiveTradeFulfillmentState::Preparing, ), ], + [], + [], ); assert_eq!(projection.bins[0].accepted_reserved_count, 0); @@ -2013,9 +3117,12 @@ mod tests { [request_record()], [declined_decision_record("decision-1")], [], + [], + [], ); assert_eq!(projection.declined_order_ids, vec!["order-1".to_string()]); + assert!(projection.cancelled_order_ids.is_empty()); assert!(projection.invalid_event_ids.is_empty()); assert!(projection.issues.is_empty()); assert_eq!(projection.bins[0].accepted_reserved_count, 0); @@ -2042,6 +3149,8 @@ mod tests { [request_record()], [decision], [], + [], + [], ); assert_eq!(projection.bins[0].accepted_reserved_count, 0); @@ -2072,6 +3181,8 @@ mod tests { accepted_decision_record_for("order-1", "decision-1", "request-1", 2), ], [], + [], + [], ); assert_eq!(projection.bins[0].available_count, 3); @@ -2104,6 +3215,8 @@ mod tests { Vec::<RadrootsActiveOrderRequestRecord>::new(), Vec::<RadrootsActiveOrderDecisionRecord>::new(), Vec::<RadrootsActiveOrderFulfillmentRecord>::new(), + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); assert_eq!(projection.bins[0].available_count, u64::MAX); @@ -2153,7 +3266,8 @@ mod tests { let mut decision = accepted_decision_record("decision-1"); decision.author_pubkey = BUYER.to_string(); - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert!(projection.issues.iter().any(|issue| matches!( @@ -2168,7 +3282,8 @@ mod tests { let mut decision = accepted_decision_record("decision-1"); decision.counterparty_pubkey = SELLER.to_string(); - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert!(projection.issues.iter().any(|issue| matches!( @@ -2190,6 +3305,8 @@ mod tests { [request_record()], [decision], [], + [], + [], ); assert_eq!(projection.bins[0].accepted_reserved_count, 0); @@ -2210,7 +3327,8 @@ mod tests { let mut decision = accepted_decision_record("decision-1"); decision.prev_event_id = "request-2".to_string(); - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert!(projection.issues.iter().any(|issue| matches!( @@ -2229,7 +3347,8 @@ mod tests { ..accepted_decision_record("decision-1") }; - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert!(projection.issues.iter().any(|issue| matches!( @@ -2251,7 +3370,8 @@ mod tests { ..accepted_decision_record("decision-1") }; - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert!(projection.issues.iter().any(|issue| matches!( @@ -2273,7 +3393,8 @@ mod tests { ..accepted_decision_record("decision-1") }; - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert_eq!( @@ -2309,7 +3430,7 @@ mod tests { ..accepted_decision_record("decision-1") }; - let projection = reduce_active_order_events("order-1", [request], [decision], []); + let projection = reduce_active_order_events("order-1", [request], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); assert!(projection.issues.is_empty()); @@ -2324,7 +3445,8 @@ mod tests { ..declined_decision_record("decision-1") }; - let projection = reduce_active_order_events("order-1", [request_record()], [decision], []); + let projection = + reduce_active_order_events("order-1", [request_record()], [decision], [], [], []); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); assert!(projection.issues.iter().any(|issue| matches!( @@ -2344,6 +3466,8 @@ mod tests { declined_decision_record("decision-1"), ], [], + [], + [], ); assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); @@ -2365,6 +3489,8 @@ mod tests { ], [], [], + [], + [], ); let reversed = reduce_active_order_events( "order-1", @@ -2374,6 +3500,8 @@ mod tests { ], [], [], + [], + [], ); assert_eq!(projection, reversed); @@ -2397,6 +3525,8 @@ mod tests { declined_decision_record("decision-1"), ], [], + [], + [], ); let reversed = reduce_active_order_events( "order-1", @@ -2406,6 +3536,8 @@ mod tests { accepted_decision_record("decision-2"), ], [], + [], + [], ); assert_eq!(projection, reversed);