commit fd4e0837f44a773b0eb44102c101f664eeabf4ec
parent 0053a148e787eb7811c53960b437c904137d9a22
Author: triesap <tyson@radroots.org>
Date: Tue, 5 May 2026 18:32:08 +0000
trade: separate payment settlement state
- add payment and settlement reducer records and projection states
- bind payment events to current agreement economics with deterministic digest checks
- reject stale payment, settlement, cancellation, and lifecycle conflicts
- cover reducer payment, settlement, and receipt separation tests
Diffstat:
3 files changed, 1291 insertions(+), 230 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2814,11 +2814,13 @@ version = "0.1.0-alpha.2"
name = "radroots_trade"
version = "0.1.0-alpha.2"
dependencies = [
+ "hex",
"radroots_core",
"radroots_events",
"radroots_events_codec",
"serde",
"serde_json",
+ "sha2",
"thiserror 1.0.69",
"ts-rs",
]
diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml
@@ -22,13 +22,20 @@ serde = [
"radroots_events/serde",
"radroots_events_codec/serde",
]
-serde_json = ["serde", "dep:serde_json", "radroots_events_codec/serde_json"]
+serde_json = [
+ "serde",
+ "dep:hex",
+ "dep:serde_json",
+ "dep:sha2",
+ "radroots_events_codec/serde_json",
+]
ts-rs = ["dep:ts-rs", "radroots_events/ts-rs", "radroots_events/std"]
[dependencies]
radroots_core = { workspace = true, default-features = false }
radroots_events = { workspace = true, default-features = false }
radroots_events_codec = { workspace = true, default-features = false }
+hex = { workspace = true, optional = true }
serde = { workspace = true, default-features = false, features = [
"alloc",
"derive",
@@ -36,5 +43,6 @@ serde = { workspace = true, default-features = false, features = [
serde_json = { workspace = true, default-features = false, features = [
"alloc",
], optional = true }
+sha2 = { workspace = true, default-features = false, optional = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, optional = true }
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -6,6 +6,7 @@ use alloc::{
vec::Vec,
};
+use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal};
use radroots_events::kinds::KIND_LISTING;
use radroots_events::trade::{
RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt,
@@ -14,8 +15,12 @@ use radroots_events::trade::{
RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem,
RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision,
RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed,
+ RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradeSettlementDecision,
+ RadrootsTradeSettlementDecisionEvent,
};
use radroots_events_codec::trade::RadrootsTradeListingAddress as TradeListingAddress;
+#[cfg(feature = "serde_json")]
+use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -108,6 +113,26 @@ pub struct RadrootsActiveOrderReceiptRecord {
}
#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsActiveOrderPaymentRecord {
+ pub event_id: String,
+ pub author_pubkey: String,
+ pub counterparty_pubkey: String,
+ pub root_event_id: String,
+ pub prev_event_id: String,
+ pub payload: RadrootsTradePaymentRecorded,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsActiveOrderSettlementRecord {
+ pub event_id: String,
+ pub author_pubkey: String,
+ pub counterparty_pubkey: String,
+ pub root_event_id: String,
+ pub prev_event_id: String,
+ pub payload: RadrootsTradeSettlementDecisionEvent,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsActiveOrderStatus {
Missing,
Requested,
@@ -120,6 +145,24 @@ pub enum RadrootsActiveOrderStatus {
}
#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsActiveOrderPaymentState {
+ NotRecorded,
+ Recorded,
+ Settled,
+ Rejected,
+ Invalid,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsActiveOrderSettlementState {
+ NotRequired,
+ Pending,
+ Accepted,
+ Rejected,
+ Invalid,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsActiveOrderReducerIssue {
MissingRequest,
MultipleRequests { event_ids: Vec<String> },
@@ -202,10 +245,95 @@ pub enum RadrootsActiveOrderReducerIssue {
ReceiptListingMismatch { event_id: String },
ReceiptRootMismatch { event_id: String },
ReceiptPreviousMismatch { event_id: String },
+ PaymentWithoutAcceptedAgreement { event_id: String },
+ PaymentPayloadInvalid { event_id: String },
+ PaymentOrderIdMismatch { event_id: String },
+ PaymentAuthorMismatch { event_id: String },
+ PaymentCounterpartyMismatch { event_id: String },
+ PaymentBuyerMismatch { event_id: String },
+ PaymentSellerMismatch { event_id: String },
+ PaymentListingAddressInvalid { event_id: String },
+ PaymentListingMismatch { event_id: String },
+ PaymentRootMismatch { event_id: String },
+ PaymentPreviousMismatch { event_id: String },
+ PaymentAgreementMismatch { event_id: String },
+ PaymentQuoteMismatch { event_id: String },
+ PaymentQuoteVersionMismatch { event_id: String },
+ PaymentEconomicsDigestMismatch { event_id: String },
+ PaymentAmountMismatch { event_id: String },
+ PaymentCurrencyMismatch { event_id: String },
+ PaymentAfterCancellation { event_id: String },
+ RevisionAfterPayment { event_id: String },
+ DuplicatePayments { event_ids: Vec<String> },
+ SettlementWithoutValidPayment { event_id: String },
+ SettlementPayloadInvalid { event_id: String },
+ SettlementOrderIdMismatch { event_id: String },
+ SettlementAuthorMismatch { event_id: String },
+ SettlementCounterpartyMismatch { event_id: String },
+ SettlementBuyerMismatch { event_id: String },
+ SettlementSellerMismatch { event_id: String },
+ SettlementListingAddressInvalid { event_id: String },
+ SettlementListingMismatch { event_id: String },
+ SettlementRootMismatch { event_id: String },
+ SettlementPreviousMismatch { event_id: String },
+ SettlementPaymentEventMismatch { event_id: String },
+ SettlementAgreementMismatch { event_id: String },
+ SettlementQuoteMismatch { event_id: String },
+ SettlementQuoteVersionMismatch { event_id: String },
+ SettlementEconomicsDigestMismatch { event_id: String },
+ SettlementAmountMismatch { event_id: String },
+ SettlementCurrencyMismatch { event_id: String },
+ DuplicateSettlements { event_ids: Vec<String> },
ForkedLifecycle { event_ids: Vec<String> },
}
#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsActiveOrderPaymentProjection {
+ pub state: RadrootsActiveOrderPaymentState,
+ pub settlement_state: RadrootsActiveOrderSettlementState,
+ pub payment_event_id: Option<String>,
+ pub settlement_event_id: Option<String>,
+ pub agreement_event_id: Option<String>,
+ pub quote_id: Option<String>,
+ pub quote_version: Option<u32>,
+ pub economics_digest: Option<String>,
+ pub amount: Option<RadrootsCoreDecimal>,
+ pub currency: Option<RadrootsCoreCurrency>,
+ pub method: Option<RadrootsTradePaymentMethod>,
+ pub reference: Option<String>,
+ pub paid_at: Option<u64>,
+ pub reason: Option<String>,
+}
+
+impl RadrootsActiveOrderPaymentProjection {
+ pub fn not_recorded() -> Self {
+ Self {
+ state: RadrootsActiveOrderPaymentState::NotRecorded,
+ settlement_state: RadrootsActiveOrderSettlementState::NotRequired,
+ payment_event_id: None,
+ settlement_event_id: None,
+ agreement_event_id: None,
+ quote_id: None,
+ quote_version: None,
+ economics_digest: None,
+ amount: None,
+ currency: None,
+ method: None,
+ reference: None,
+ paid_at: None,
+ reason: None,
+ }
+ }
+
+ pub fn invalid() -> Self {
+ let mut projection = Self::not_recorded();
+ projection.state = RadrootsActiveOrderPaymentState::Invalid;
+ projection.settlement_state = RadrootsActiveOrderSettlementState::Invalid;
+ projection
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsActiveOrderProjection {
pub order_id: String,
pub status: RadrootsActiveOrderStatus,
@@ -219,8 +347,7 @@ pub struct RadrootsActiveOrderProjection {
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 payment: RadrootsActiveOrderPaymentProjection,
pub economics: Option<RadrootsTradeOrderEconomics>,
pub agreement_event_id: Option<String>,
pub listing_addr: Option<String>,
@@ -230,6 +357,13 @@ pub struct RadrootsActiveOrderProjection {
pub issues: Vec<RadrootsActiveOrderReducerIssue>,
}
+#[cfg(feature = "serde_json")]
+#[derive(Debug, Error)]
+pub enum RadrootsTradeOrderEconomicsDigestError {
+ #[error("failed to serialize order economics for digest: {0}")]
+ Serialize(#[from] serde_json::Error),
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsListingInventoryBinAvailability {
pub bin_id: String,
@@ -286,7 +420,7 @@ pub struct RadrootsListingInventoryAccountingProjection {
pub issues: Vec<RadrootsListingInventoryAccountingIssue>,
}
-pub fn reduce_active_order_events<I, J, K, L, M, N, O>(
+pub fn reduce_active_order_events<I, J, K, L, M, N, O, P, Q>(
order_id: &str,
requests: I,
decisions: J,
@@ -295,6 +429,8 @@ pub fn reduce_active_order_events<I, J, K, L, M, N, O>(
fulfillments: M,
cancellations: N,
receipts: O,
+ payments: P,
+ settlements: Q,
) -> RadrootsActiveOrderProjection
where
I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>,
@@ -304,6 +440,8 @@ where
M: IntoIterator<Item = RadrootsActiveOrderFulfillmentRecord>,
N: IntoIterator<Item = RadrootsActiveOrderCancellationRecord>,
O: IntoIterator<Item = RadrootsActiveOrderReceiptRecord>,
+ P: IntoIterator<Item = RadrootsActiveOrderPaymentRecord>,
+ Q: IntoIterator<Item = RadrootsActiveOrderSettlementRecord>,
{
let requests = unique_request_records(requests);
let decisions = unique_decision_records(decisions);
@@ -312,6 +450,8 @@ where
let fulfillments = unique_fulfillment_records(fulfillments);
let cancellations = unique_cancellation_records(cancellations);
let receipts = unique_receipt_records(receipts);
+ let payments = unique_payment_records(payments);
+ let settlements = unique_settlement_records(settlements);
if requests.is_empty()
&& decisions.is_empty()
&& revision_proposals.is_empty()
@@ -319,6 +459,8 @@ where
&& fulfillments.is_empty()
&& cancellations.is_empty()
&& receipts.is_empty()
+ && payments.is_empty()
+ && settlements.is_empty()
{
return RadrootsActiveOrderProjection {
order_id: order_id.to_string(),
@@ -333,8 +475,7 @@ where
receipt_issue: None,
receipt_received_at: None,
lifecycle_terminal: false,
- settlement_pending: false,
- settlement_reason: None,
+ payment: RadrootsActiveOrderPaymentProjection::not_recorded(),
economics: None,
agreement_event_id: None,
listing_addr: None,
@@ -369,6 +510,8 @@ where
&& fulfillments.is_empty()
&& cancellations.is_empty()
&& receipts.is_empty()
+ && payments.is_empty()
+ && settlements.is_empty()
{
return invalid_projection(order_id, None, issues);
}
@@ -456,8 +599,15 @@ where
if !valid_receipts.is_empty() {
record_receipt_without_eligible_fulfillment(&valid_receipts, &mut issues);
}
+ record_payment_without_accepted_agreement(&payments, &mut issues);
+ record_settlement_without_valid_payment(&settlements, &mut issues);
if !issues.is_empty() {
- invalid_projection(order_id, Some(request), issues)
+ invalid_projection_with_payment(
+ order_id,
+ Some(request),
+ issues,
+ RadrootsActiveOrderPaymentProjection::invalid(),
+ )
} else if valid_cancellations.is_empty() {
requested_projection(order_id, request)
} else {
@@ -473,6 +623,8 @@ where
fulfillments,
valid_cancellations,
valid_receipts,
+ payments,
+ settlements,
),
_ => {
let mut event_ids = valid_decisions
@@ -598,6 +750,8 @@ where
order_fulfillments.clone(),
order_cancellations.clone(),
order_receipts.clone(),
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
);
match projection.status {
RadrootsActiveOrderStatus::Accepted
@@ -815,6 +969,17 @@ pub fn canonicalize_active_order_decision_for_signer(
Ok(decision_event)
}
+#[cfg(feature = "serde_json")]
+pub fn radroots_trade_order_economics_digest(
+ economics: &RadrootsTradeOrderEconomics,
+) -> Result<String, RadrootsTradeOrderEconomicsDigestError> {
+ let encoded = serde_json::to_vec(economics)?;
+ let digest = Sha256::digest(encoded);
+ let mut value = String::from("sha256:");
+ value.push_str(&hex::encode(digest));
+ Ok(value)
+}
+
fn unique_request_records<I>(requests: I) -> Vec<RadrootsActiveOrderRequestRecord>
where
I: IntoIterator<Item = RadrootsActiveOrderRequestRecord>,
@@ -959,6 +1124,46 @@ where
unique
}
+fn unique_payment_records<I>(payments: I) -> Vec<RadrootsActiveOrderPaymentRecord>
+where
+ I: IntoIterator<Item = RadrootsActiveOrderPaymentRecord>,
+{
+ let mut unique = Vec::new();
+ let mut records = payments.into_iter().collect::<Vec<_>>();
+ records.sort_by(|left, right| left.event_id.cmp(&right.event_id));
+ for payment in records {
+ if unique
+ .iter()
+ .all(|existing: &RadrootsActiveOrderPaymentRecord| {
+ existing.event_id != payment.event_id
+ })
+ {
+ unique.push(payment);
+ }
+ }
+ unique
+}
+
+fn unique_settlement_records<I>(settlements: I) -> Vec<RadrootsActiveOrderSettlementRecord>
+where
+ I: IntoIterator<Item = RadrootsActiveOrderSettlementRecord>,
+{
+ let mut unique = Vec::new();
+ let mut records = settlements.into_iter().collect::<Vec<_>>();
+ records.sort_by(|left, right| left.event_id.cmp(&right.event_id));
+ for settlement in records {
+ if unique
+ .iter()
+ .all(|existing: &RadrootsActiveOrderSettlementRecord| {
+ existing.event_id != settlement.event_id
+ })
+ {
+ unique.push(settlement);
+ }
+ }
+ unique
+}
+
fn normalized_listing_inventory_bins<I>(
bins: I,
) -> (
@@ -1158,6 +1363,8 @@ fn projection_issue_event_ids(issues: &[RadrootsActiveOrderReducerIssue]) -> Vec
RadrootsActiveOrderReducerIssue::MissingRequest => {}
RadrootsActiveOrderReducerIssue::MultipleRequests { event_ids: ids }
| RadrootsActiveOrderReducerIssue::ConflictingDecisions { event_ids: ids }
+ | RadrootsActiveOrderReducerIssue::DuplicatePayments { event_ids: ids }
+ | RadrootsActiveOrderReducerIssue::DuplicateSettlements { event_ids: ids }
| RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids: ids } => {
event_ids.extend(ids.iter().cloned());
}
@@ -1239,7 +1446,44 @@ fn projection_issue_event_ids(issues: &[RadrootsActiveOrderReducerIssue]) -> Vec
| RadrootsActiveOrderReducerIssue::ReceiptListingAddressInvalid { event_id }
| RadrootsActiveOrderReducerIssue::ReceiptListingMismatch { event_id }
| RadrootsActiveOrderReducerIssue::ReceiptRootMismatch { event_id }
- | RadrootsActiveOrderReducerIssue::ReceiptPreviousMismatch { event_id } => {
+ | RadrootsActiveOrderReducerIssue::ReceiptPreviousMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentWithoutAcceptedAgreement { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentPayloadInvalid { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentOrderIdMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentAuthorMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentCounterpartyMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentBuyerMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentSellerMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentListingAddressInvalid { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentListingMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentRootMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentPreviousMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentAgreementMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentQuoteMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentQuoteVersionMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentEconomicsDigestMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentAmountMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentCurrencyMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::PaymentAfterCancellation { event_id }
+ | RadrootsActiveOrderReducerIssue::RevisionAfterPayment { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementWithoutValidPayment { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementPayloadInvalid { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementOrderIdMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementAuthorMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementCounterpartyMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementBuyerMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementSellerMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementListingAddressInvalid { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementListingMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementRootMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementPreviousMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementPaymentEventMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementAgreementMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementQuoteMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementQuoteVersionMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementEconomicsDigestMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementAmountMismatch { event_id }
+ | RadrootsActiveOrderReducerIssue::SettlementCurrencyMismatch { event_id } => {
event_ids.push(event_id.clone());
}
RadrootsActiveOrderReducerIssue::ForkedFulfillments { event_ids: ids } => {
@@ -1902,90 +2146,569 @@ fn validate_active_receipt_record(
valid
}
-fn decision_payload_issue(
- decision: &RadrootsTradeOrderDecision,
- event_id: &str,
+fn reduce_active_payment_settlement_records(
+ request: &RadrootsActiveOrderRequestRecord,
+ agreement_event_id: &str,
+ economics: &RadrootsTradeOrderEconomics,
+ payments: Vec<RadrootsActiveOrderPaymentRecord>,
+ settlements: Vec<RadrootsActiveOrderSettlementRecord>,
issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
-) -> bool {
- match decision {
- RadrootsTradeOrderDecision::Accepted {
- inventory_commitments,
- } => {
- if inventory_commitments.is_empty() {
+) -> RadrootsActiveOrderPaymentProjection {
+ let mut valid_payments = Vec::new();
+ for payment in payments {
+ if validate_active_payment_record(request, &payment, issues) {
+ valid_payments.push(payment);
+ }
+ }
+ let mut valid_settlements = Vec::new();
+ for settlement in settlements {
+ if validate_active_settlement_record(request, &settlement, issues) {
+ valid_settlements.push(settlement);
+ }
+ }
+ if !issues.is_empty() {
+ return RadrootsActiveOrderPaymentProjection::invalid();
+ }
+ if valid_payments.is_empty() {
+ record_settlement_without_valid_payment(&valid_settlements, issues);
+ return if issues.is_empty() {
+ RadrootsActiveOrderPaymentProjection::not_recorded()
+ } else {
+ RadrootsActiveOrderPaymentProjection::invalid()
+ };
+ }
+
+ let mut previous_payment_parent = agreement_event_id.to_string();
+ let mut used_payment_event_ids = Vec::new();
+ let mut used_settlement_event_ids = Vec::new();
+ let mut rejected_projection = None;
+
+ loop {
+ let payment_candidates = valid_payments
+ .iter()
+ .filter(|payment| {
+ payment.prev_event_id == previous_payment_parent
+ && payment.payload.previous_event_id == previous_payment_parent
+ && !used_payment_event_ids.contains(&payment.event_id)
+ })
+ .collect::<Vec<_>>();
+ if payment_candidates.is_empty() {
+ for payment in valid_payments
+ .iter()
+ .filter(|payment| !used_payment_event_ids.contains(&payment.event_id))
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentPreviousMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ }
+ for settlement in valid_settlements
+ .iter()
+ .filter(|settlement| !used_settlement_event_ids.contains(&settlement.event_id))
+ {
issues.push(
- RadrootsActiveOrderReducerIssue::DecisionMissingInventoryCommitments {
- event_id: event_id.to_string(),
+ RadrootsActiveOrderReducerIssue::SettlementWithoutValidPayment {
+ event_id: settlement.event_id.clone(),
},
);
- true
- } else {
- false
}
+ return if issues.is_empty() {
+ rejected_projection
+ .unwrap_or_else(RadrootsActiveOrderPaymentProjection::not_recorded)
+ } else {
+ RadrootsActiveOrderPaymentProjection::invalid()
+ };
}
- RadrootsTradeOrderDecision::Declined { reason } => {
- if reason.trim().is_empty() {
- issues.push(RadrootsActiveOrderReducerIssue::DecisionMissingReason {
- event_id: event_id.to_string(),
- });
- true
+ if payment_candidates.len() > 1 {
+ let mut event_ids = payment_candidates
+ .iter()
+ .map(|payment| payment.event_id.clone())
+ .collect::<Vec<_>>();
+ event_ids.sort();
+ issues.push(RadrootsActiveOrderReducerIssue::DuplicatePayments { event_ids });
+ return RadrootsActiveOrderPaymentProjection::invalid();
+ }
+ let payment = payment_candidates[0];
+ validate_active_payment_agreement_record(payment, agreement_event_id, economics, issues);
+ if !issues.is_empty() {
+ return RadrootsActiveOrderPaymentProjection::invalid();
+ }
+ used_payment_event_ids.push(payment.event_id.clone());
+
+ let settlement_candidates = valid_settlements
+ .iter()
+ .filter(|settlement| {
+ settlement.prev_event_id == payment.event_id
+ && settlement.payload.previous_event_id == payment.event_id
+ && settlement.payload.payment_event_id == payment.event_id
+ && !used_settlement_event_ids.contains(&settlement.event_id)
+ })
+ .collect::<Vec<_>>();
+ if settlement_candidates.is_empty() {
+ for settlement in valid_settlements
+ .iter()
+ .filter(|settlement| !used_settlement_event_ids.contains(&settlement.event_id))
+ {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementWithoutValidPayment {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+ return if issues.is_empty() {
+ payment_projection_from_record(
+ payment,
+ RadrootsActiveOrderPaymentState::Recorded,
+ RadrootsActiveOrderSettlementState::Pending,
+ None,
+ )
} else {
- false
+ RadrootsActiveOrderPaymentProjection::invalid()
+ };
+ }
+ if settlement_candidates.len() > 1 {
+ let mut event_ids = settlement_candidates
+ .iter()
+ .map(|settlement| settlement.event_id.clone())
+ .collect::<Vec<_>>();
+ event_ids.sort();
+ issues.push(RadrootsActiveOrderReducerIssue::DuplicateSettlements { event_ids });
+ return RadrootsActiveOrderPaymentProjection::invalid();
+ }
+ let settlement = settlement_candidates[0];
+ validate_active_settlement_payment_record(settlement, payment, issues);
+ if !issues.is_empty() {
+ return RadrootsActiveOrderPaymentProjection::invalid();
+ }
+ used_settlement_event_ids.push(settlement.event_id.clone());
+ match settlement.payload.decision {
+ RadrootsTradeSettlementDecision::Accepted => {
+ for payment in valid_payments
+ .iter()
+ .filter(|payment| !used_payment_event_ids.contains(&payment.event_id))
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentPreviousMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ }
+ for settlement in valid_settlements
+ .iter()
+ .filter(|settlement| !used_settlement_event_ids.contains(&settlement.event_id))
+ {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementWithoutValidPayment {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+ return if issues.is_empty() {
+ payment_projection_from_record(
+ payment,
+ RadrootsActiveOrderPaymentState::Settled,
+ RadrootsActiveOrderSettlementState::Accepted,
+ Some(settlement),
+ )
+ } else {
+ RadrootsActiveOrderPaymentProjection::invalid()
+ };
+ }
+ RadrootsTradeSettlementDecision::Rejected => {
+ rejected_projection = Some(payment_projection_from_record(
+ payment,
+ RadrootsActiveOrderPaymentState::Rejected,
+ RadrootsActiveOrderSettlementState::Rejected,
+ Some(settlement),
+ ));
+ previous_payment_parent = settlement.event_id.clone();
}
}
}
}
-fn record_fulfillment_without_accepted_decision(
- fulfillments: &[RadrootsActiveOrderFulfillmentRecord],
- issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
-) {
- for fulfillment in fulfillments {
- issues.push(
- RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision {
- event_id: fulfillment.event_id.clone(),
- },
- );
+fn payment_projection_from_record(
+ payment: &RadrootsActiveOrderPaymentRecord,
+ state: RadrootsActiveOrderPaymentState,
+ settlement_state: RadrootsActiveOrderSettlementState,
+ settlement: Option<&RadrootsActiveOrderSettlementRecord>,
+) -> RadrootsActiveOrderPaymentProjection {
+ RadrootsActiveOrderPaymentProjection {
+ state,
+ settlement_state,
+ payment_event_id: Some(payment.event_id.clone()),
+ settlement_event_id: settlement.map(|settlement| settlement.event_id.clone()),
+ agreement_event_id: Some(payment.payload.agreement_event_id.clone()),
+ quote_id: Some(payment.payload.quote_id.clone()),
+ quote_version: Some(payment.payload.quote_version),
+ economics_digest: Some(payment.payload.economics_digest.clone()),
+ amount: Some(payment.payload.amount),
+ currency: Some(payment.payload.currency),
+ method: Some(payment.payload.method),
+ reference: payment.payload.reference.clone(),
+ paid_at: payment.payload.paid_at,
+ reason: settlement.and_then(|settlement| settlement.payload.reason.clone()),
}
}
-fn record_revision_proposal_without_accepted_decision(
- revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord],
+fn validate_active_payment_record(
+ request: &RadrootsActiveOrderRequestRecord,
+ payment: &RadrootsActiveOrderPaymentRecord,
issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
-) {
- for proposal in revision_proposals {
+) -> bool {
+ let mut valid = true;
+ if payment.payload.validate().is_err() {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentPayloadInvalid {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ if payment.payload.order_id != request.payload.order_id {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentOrderIdMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ if payment.author_pubkey != payment.payload.buyer_pubkey {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentAuthorMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ if payment.counterparty_pubkey != request.payload.seller_pubkey {
issues.push(
- RadrootsActiveOrderReducerIssue::RevisionProposalWithoutAcceptedDecision {
- event_id: proposal.event_id.clone(),
+ RadrootsActiveOrderReducerIssue::PaymentCounterpartyMismatch {
+ event_id: payment.event_id.clone(),
},
);
+ valid = false;
+ }
+ if payment.payload.buyer_pubkey != request.payload.buyer_pubkey {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentBuyerMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ if payment.payload.seller_pubkey != request.payload.seller_pubkey {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentSellerMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ match parse_public_listing_addr(&payment.payload.listing_addr) {
+ Ok(listing_addr) => {
+ if payment.payload.listing_addr != request.payload.listing_addr
+ || listing_addr.seller_pubkey != payment.payload.seller_pubkey
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentListingMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ }
+ Err(_) => {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::PaymentListingAddressInvalid {
+ event_id: payment.event_id.clone(),
+ },
+ );
+ valid = false;
+ }
+ }
+ if payment.root_event_id != request.event_id
+ || payment.payload.root_event_id != request.event_id
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentRootMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
+ }
+ if payment.prev_event_id.trim().is_empty()
+ || payment.prev_event_id == payment.event_id
+ || payment.payload.previous_event_id != payment.prev_event_id
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentPreviousMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ valid = false;
}
+ valid
}
-fn record_revision_decision_without_proposal(
- revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord],
+fn validate_active_payment_agreement_record(
+ payment: &RadrootsActiveOrderPaymentRecord,
+ agreement_event_id: &str,
+ economics: &RadrootsTradeOrderEconomics,
issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
) {
- for decision in revision_decisions {
+ if payment.payload.agreement_event_id != agreement_event_id {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentAgreementMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ }
+ if payment.payload.quote_id != economics.quote_id {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentQuoteMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ }
+ if payment.payload.quote_version != economics.quote_version {
issues.push(
- RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal {
- event_id: decision.event_id.clone(),
+ RadrootsActiveOrderReducerIssue::PaymentQuoteVersionMismatch {
+ event_id: payment.event_id.clone(),
},
);
}
+ if payment.payload.amount != economics.total.amount {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentAmountMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ }
+ if payment.payload.currency != economics.total.currency
+ || payment.payload.currency != economics.currency
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentCurrencyMismatch {
+ event_id: payment.event_id.clone(),
+ });
+ }
+ #[cfg(feature = "serde_json")]
+ match radroots_trade_order_economics_digest(economics) {
+ Ok(expected_digest) if payment.payload.economics_digest != expected_digest => {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::PaymentEconomicsDigestMismatch {
+ event_id: payment.event_id.clone(),
+ },
+ );
+ }
+ Ok(_) => {}
+ Err(_) => {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::PaymentEconomicsDigestMismatch {
+ event_id: payment.event_id.clone(),
+ },
+ );
+ }
+ }
}
-fn record_cancellation_without_cancellable_order(
- cancellations: &[RadrootsActiveOrderCancellationRecord],
+fn validate_active_settlement_record(
+ request: &RadrootsActiveOrderRequestRecord,
+ settlement: &RadrootsActiveOrderSettlementRecord,
issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
-) {
- for cancellation in cancellations {
+) -> bool {
+ let mut valid = true;
+ if settlement.payload.validate().is_err() {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementPayloadInvalid {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ if settlement.payload.order_id != request.payload.order_id {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementOrderIdMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ if settlement.author_pubkey != settlement.payload.seller_pubkey {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementAuthorMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ if settlement.counterparty_pubkey != request.payload.buyer_pubkey {
issues.push(
- RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder {
- event_id: cancellation.event_id.clone(),
+ RadrootsActiveOrderReducerIssue::SettlementCounterpartyMismatch {
+ event_id: settlement.event_id.clone(),
},
);
+ valid = false;
}
-}
+ if settlement.payload.buyer_pubkey != request.payload.buyer_pubkey {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementBuyerMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ if settlement.payload.seller_pubkey != request.payload.seller_pubkey {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementSellerMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ match parse_public_listing_addr(&settlement.payload.listing_addr) {
+ Ok(listing_addr) => {
+ if settlement.payload.listing_addr != request.payload.listing_addr
+ || listing_addr.seller_pubkey != settlement.payload.seller_pubkey
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementListingMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ }
+ Err(_) => {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementListingAddressInvalid {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ valid = false;
+ }
+ }
+ if settlement.root_event_id != request.event_id
+ || settlement.payload.root_event_id != request.event_id
+ {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementRootMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ valid = false;
+ }
+ if settlement.prev_event_id.trim().is_empty()
+ || settlement.prev_event_id == settlement.event_id
+ || settlement.payload.previous_event_id != settlement.prev_event_id
+ {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementPreviousMismatch {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ valid = false;
+ }
+ valid
+}
+
+fn validate_active_settlement_payment_record(
+ settlement: &RadrootsActiveOrderSettlementRecord,
+ payment: &RadrootsActiveOrderPaymentRecord,
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ if settlement.payload.payment_event_id != payment.event_id {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementPaymentEventMismatch {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+ if settlement.payload.agreement_event_id != payment.payload.agreement_event_id {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementAgreementMismatch {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+ if settlement.payload.quote_id != payment.payload.quote_id {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementQuoteMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ }
+ if settlement.payload.quote_version != payment.payload.quote_version {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementQuoteVersionMismatch {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+ if settlement.payload.economics_digest != payment.payload.economics_digest {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementEconomicsDigestMismatch {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+ if settlement.payload.amount != payment.payload.amount {
+ issues.push(RadrootsActiveOrderReducerIssue::SettlementAmountMismatch {
+ event_id: settlement.event_id.clone(),
+ });
+ }
+ if settlement.payload.currency != payment.payload.currency {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementCurrencyMismatch {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+}
+
+fn decision_payload_issue(
+ decision: &RadrootsTradeOrderDecision,
+ event_id: &str,
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) -> bool {
+ match decision {
+ RadrootsTradeOrderDecision::Accepted {
+ inventory_commitments,
+ } => {
+ if inventory_commitments.is_empty() {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::DecisionMissingInventoryCommitments {
+ event_id: event_id.to_string(),
+ },
+ );
+ true
+ } else {
+ false
+ }
+ }
+ RadrootsTradeOrderDecision::Declined { reason } => {
+ if reason.trim().is_empty() {
+ issues.push(RadrootsActiveOrderReducerIssue::DecisionMissingReason {
+ event_id: event_id.to_string(),
+ });
+ true
+ } else {
+ false
+ }
+ }
+ }
+}
+
+fn record_fulfillment_without_accepted_decision(
+ fulfillments: &[RadrootsActiveOrderFulfillmentRecord],
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ for fulfillment in fulfillments {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision {
+ event_id: fulfillment.event_id.clone(),
+ },
+ );
+ }
+}
+
+fn record_revision_proposal_without_accepted_decision(
+ revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord],
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ for proposal in revision_proposals {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::RevisionProposalWithoutAcceptedDecision {
+ event_id: proposal.event_id.clone(),
+ },
+ );
+ }
+}
+
+fn record_revision_decision_without_proposal(
+ revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord],
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ for decision in revision_decisions {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::RevisionDecisionWithoutProposal {
+ event_id: decision.event_id.clone(),
+ },
+ );
+ }
+}
+
+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],
@@ -2000,6 +2723,43 @@ fn record_receipt_without_eligible_fulfillment(
}
}
+fn record_payment_without_accepted_agreement(
+ payments: &[RadrootsActiveOrderPaymentRecord],
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ for payment in payments {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::PaymentWithoutAcceptedAgreement {
+ event_id: payment.event_id.clone(),
+ },
+ );
+ }
+}
+
+fn record_settlement_without_valid_payment(
+ settlements: &[RadrootsActiveOrderSettlementRecord],
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ for settlement in settlements {
+ issues.push(
+ RadrootsActiveOrderReducerIssue::SettlementWithoutValidPayment {
+ event_id: settlement.event_id.clone(),
+ },
+ );
+ }
+}
+
+fn record_payment_after_cancellation(
+ payments: &[RadrootsActiveOrderPaymentRecord],
+ issues: &mut Vec<RadrootsActiveOrderReducerIssue>,
+) {
+ for payment in payments {
+ issues.push(RadrootsActiveOrderReducerIssue::PaymentAfterCancellation {
+ event_id: payment.event_id.clone(),
+ });
+ }
+}
+
fn single_lifecycle_child<T>(
records: &[T],
event_id: impl Fn(&T) -> &String,
@@ -2238,8 +2998,7 @@ fn requested_projection(
receipt_issue: None,
receipt_received_at: None,
lifecycle_terminal: false,
- settlement_pending: false,
- settlement_reason: None,
+ payment: RadrootsActiveOrderPaymentProjection::not_recorded(),
economics: Some(request.payload.economics.clone()),
agreement_event_id: None,
listing_addr: Some(request.payload.listing_addr.clone()),
@@ -2296,182 +3055,252 @@ fn decided_projection(
fulfillments: Vec<RadrootsActiveOrderFulfillmentRecord>,
cancellations: Vec<RadrootsActiveOrderCancellationRecord>,
receipts: Vec<RadrootsActiveOrderReceiptRecord>,
+ payments: Vec<RadrootsActiveOrderPaymentRecord>,
+ settlements: Vec<RadrootsActiveOrderSettlementRecord>,
) -> RadrootsActiveOrderProjection {
let status = match &decision.payload.decision {
RadrootsTradeOrderDecision::Accepted { .. } => RadrootsActiveOrderStatus::Accepted,
RadrootsTradeOrderDecision::Declined { .. } => RadrootsActiveOrderStatus::Declined,
};
let mut issues = Vec::new();
- let (fulfillment_event_id, fulfillment_status, last_event_id, agreement_event_id, economics) =
- match status {
- RadrootsActiveOrderStatus::Accepted => {
- let Some(revision_state) = active_revision_state(
- request,
- decision,
- &revision_proposals,
- &revision_decisions,
- &mut issues,
- ) else {
- return invalid_projection(order_id, Some(request), issues);
- };
- if let Some(pending_revision_event_id) =
- revision_state.pending_revision_event_id.as_ref()
- && (!fulfillments.is_empty()
- || !cancellations.is_empty()
- || !receipts.is_empty())
- {
- let mut event_ids = vec![pending_revision_event_id.clone()];
- event_ids.extend(
- fulfillments
- .iter()
- .map(|fulfillment| fulfillment.event_id.clone()),
- );
- event_ids.extend(
- cancellations
- .iter()
- .map(|cancellation| cancellation.event_id.clone()),
- );
- event_ids.extend(receipts.iter().map(|receipt| receipt.event_id.clone()));
- sort_and_dedup_strings(&mut event_ids);
- return invalid_projection(
+ let (
+ fulfillment_event_id,
+ fulfillment_status,
+ last_event_id,
+ agreement_event_id,
+ economics,
+ payment,
+ ) = match status {
+ RadrootsActiveOrderStatus::Accepted => {
+ let Some(revision_state) = active_revision_state(
+ request,
+ decision,
+ &revision_proposals,
+ &revision_decisions,
+ &mut issues,
+ ) else {
+ return invalid_projection(order_id, Some(request), issues);
+ };
+ if let Some(pending_revision_event_id) =
+ revision_state.pending_revision_event_id.as_ref()
+ && (!fulfillments.is_empty()
+ || !cancellations.is_empty()
+ || !receipts.is_empty()
+ || !payments.is_empty()
+ || !settlements.is_empty())
+ {
+ let mut event_ids = vec![pending_revision_event_id.clone()];
+ event_ids.extend(
+ fulfillments
+ .iter()
+ .map(|fulfillment| fulfillment.event_id.clone()),
+ );
+ event_ids.extend(
+ cancellations
+ .iter()
+ .map(|cancellation| cancellation.event_id.clone()),
+ );
+ event_ids.extend(receipts.iter().map(|receipt| receipt.event_id.clone()));
+ event_ids.extend(payments.iter().map(|payment| payment.event_id.clone()));
+ event_ids.extend(
+ settlements
+ .iter()
+ .map(|settlement| settlement.event_id.clone()),
+ );
+ sort_and_dedup_strings(&mut event_ids);
+ return invalid_projection(
+ order_id,
+ Some(request),
+ vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }],
+ );
+ }
+ let fulfillment_records =
+ validated_fulfillment_records(request, fulfillments, &mut issues);
+ let latest = latest_fulfillment_record(
+ &revision_state.lifecycle_parent_event_id,
+ &fulfillment_records,
+ &mut issues,
+ );
+ if !issues.is_empty() {
+ return invalid_projection(order_id, Some(request), issues);
+ }
+ let decision_cancellations = cancellations
+ .iter()
+ .cloned()
+ .filter(|cancellation| {
+ cancellation.prev_event_id == revision_state.lifecycle_parent_event_id
+ })
+ .collect::<Vec<_>>();
+ for cancellation in cancellations.iter().filter(|cancellation| {
+ cancellation.prev_event_id != revision_state.lifecycle_parent_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 !decision_cancellations.is_empty() {
+ record_payment_after_cancellation(&payments, &mut issues);
+ record_settlement_without_valid_payment(&settlements, &mut issues);
+ if !issues.is_empty() {
+ return invalid_projection_with_payment(
order_id,
Some(request),
- vec![RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids }],
+ issues,
+ RadrootsActiveOrderPaymentProjection::invalid(),
);
}
- let fulfillment_records =
- validated_fulfillment_records(request, fulfillments, &mut issues);
- let latest = latest_fulfillment_record(
- &revision_state.lifecycle_parent_event_id,
- &fulfillment_records,
- &mut issues,
- );
- if !issues.is_empty() {
- return invalid_projection(order_id, Some(request), issues);
- }
- let decision_cancellations = cancellations
+ }
+ if let Some(first_fulfillment) = fulfillment_records.iter().find(|fulfillment| {
+ fulfillment.prev_event_id == revision_state.lifecycle_parent_event_id
+ }) && !decision_cancellations.is_empty()
+ {
+ let mut event_ids = decision_cancellations
.iter()
- .cloned()
- .filter(|cancellation| {
- cancellation.prev_event_id == revision_state.lifecycle_parent_event_id
- })
+ .map(|cancellation| cancellation.event_id.clone())
.collect::<Vec<_>>();
- for cancellation in cancellations.iter().filter(|cancellation| {
- cancellation.prev_event_id != revision_state.lifecycle_parent_event_id
- }) {
+ 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::CancellationPreviousMismatch {
- event_id: cancellation.event_id.clone(),
+ RadrootsActiveOrderReducerIssue::CancellationAfterFulfillment {
+ event_id: cancellation.event_id,
},
);
}
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 == revision_state.lifecycle_parent_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,
- },
+ } 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()),
+ Some(revision_state.agreement_event_id.clone()),
+ revision_state.economics.clone(),
+ cancellation,
);
}
- 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()),
- Some(revision_state.agreement_event_id.clone()),
- revision_state.economics.clone(),
- cancellation,
- );
- }
- Ok(None) => {}
- Err(issue) => {
- return invalid_projection(order_id, Some(request), vec![issue]);
- }
+ Ok(None) => {}
+ Err(issue) => {
+ return invalid_projection(order_id, Some(request), vec![issue]);
}
}
- let receipt_result = receipt_projection(
+ }
+ let payment = reduce_active_payment_settlement_records(
+ request,
+ &revision_state.agreement_event_id,
+ &revision_state.economics,
+ payments,
+ settlements,
+ &mut issues,
+ );
+ if !issues.is_empty() {
+ return invalid_projection_with_payment(
order_id,
- request,
- decision,
- &revision_state.agreement_event_id,
- &revision_state.economics,
- latest.as_ref(),
- &fulfillment_records,
- receipts,
- &mut issues,
+ Some(request),
+ issues,
+ RadrootsActiveOrderPaymentProjection::invalid(),
);
- if let Some(projection) = receipt_result {
- return projection;
- }
- if !issues.is_empty() {
- return invalid_projection(order_id, Some(request), issues);
- }
- let (fulfillment_event_id, fulfillment_status, last_event_id) = match latest {
- Some(fulfillment) => (
- Some(fulfillment.event_id.clone()),
- Some(fulfillment.payload.status),
- Some(fulfillment.event_id),
- ),
- None => (
- None,
- Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled),
- Some(revision_state.lifecycle_parent_event_id.clone()),
- ),
- };
+ }
+ let receipt_result = receipt_projection(
+ order_id,
+ request,
+ decision,
+ &revision_state.agreement_event_id,
+ &revision_state.economics,
+ latest.as_ref(),
+ &fulfillment_records,
+ receipts,
+ &mut issues,
+ );
+ if let Some(mut projection) = receipt_result {
+ projection.payment = payment;
+ return projection;
+ }
+ if !issues.is_empty() {
+ return invalid_projection(order_id, Some(request), issues);
+ }
+ let (fulfillment_event_id, fulfillment_status, last_event_id) = match latest {
+ Some(fulfillment) => (
+ Some(fulfillment.event_id.clone()),
+ Some(fulfillment.payload.status),
+ Some(fulfillment.event_id),
+ ),
+ None => (
+ None,
+ Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled),
+ Some(revision_state.lifecycle_parent_event_id.clone()),
+ ),
+ };
+ let mut projection_payment = payment;
+ if projection_payment.state == RadrootsActiveOrderPaymentState::NotRecorded {
+ projection_payment.settlement_state =
+ RadrootsActiveOrderSettlementState::NotRequired;
+ }
+ (
+ fulfillment_event_id,
+ fulfillment_status,
+ last_event_id,
+ Some(revision_state.agreement_event_id),
+ Some(revision_state.economics),
+ projection_payment,
+ )
+ }
+ RadrootsActiveOrderStatus::Declined => {
+ record_revision_proposal_without_accepted_decision(&revision_proposals, &mut issues);
+ record_revision_decision_without_proposal(&revision_decisions, &mut issues);
+ record_payment_without_accepted_agreement(&payments, &mut issues);
+ record_settlement_without_valid_payment(&settlements, &mut issues);
+ if fulfillments.is_empty()
+ && cancellations.is_empty()
+ && receipts.is_empty()
+ && payments.is_empty()
+ && settlements.is_empty()
+ && issues.is_empty()
+ {
(
- fulfillment_event_id,
- fulfillment_status,
- last_event_id,
- Some(revision_state.agreement_event_id),
- Some(revision_state.economics),
+ None,
+ None,
+ Some(decision.event_id.clone()),
+ None,
+ None,
+ RadrootsActiveOrderPaymentProjection::not_recorded(),
)
- }
- RadrootsActiveOrderStatus::Declined => {
- record_revision_proposal_without_accepted_decision(
- &revision_proposals,
- &mut issues,
+ } 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_with_payment(
+ order_id,
+ Some(request),
+ issues,
+ RadrootsActiveOrderPaymentProjection::invalid(),
);
- record_revision_decision_without_proposal(&revision_decisions, &mut issues);
- if fulfillments.is_empty()
- && cancellations.is_empty()
- && receipts.is_empty()
- && issues.is_empty()
- {
- (None, None, Some(decision.event_id.clone()), None, None)
- } 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);
- }
}
- _ => (None, None, Some(decision.event_id.clone()), None, None),
- };
+ }
+ _ => (
+ None,
+ None,
+ Some(decision.event_id.clone()),
+ None,
+ None,
+ RadrootsActiveOrderPaymentProjection::not_recorded(),
+ ),
+ };
RadrootsActiveOrderProjection {
order_id: order_id.to_string(),
status,
@@ -2485,8 +3314,7 @@ fn decided_projection(
receipt_issue: None,
receipt_received_at: None,
lifecycle_terminal: false,
- settlement_pending: false,
- settlement_reason: None,
+ payment,
economics,
agreement_event_id,
listing_addr: Some(request.payload.listing_addr.clone()),
@@ -2606,8 +3434,7 @@ fn cancelled_projection(
receipt_issue: None,
receipt_received_at: None,
lifecycle_terminal: true,
- settlement_pending: true,
- settlement_reason: Some(cancellation.payload.reason),
+ payment: RadrootsActiveOrderPaymentProjection::not_recorded(),
economics: Some(economics),
agreement_event_id,
listing_addr: Some(request.payload.listing_addr.clone()),
@@ -2645,8 +3472,7 @@ fn receipt_terminal_projection(
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,
+ payment: RadrootsActiveOrderPaymentProjection::not_recorded(),
economics: Some(economics.clone()),
agreement_event_id: Some(agreement_event_id.to_string()),
listing_addr: Some(request.payload.listing_addr.clone()),
@@ -2662,6 +3488,20 @@ fn invalid_projection(
request: Option<&RadrootsActiveOrderRequestRecord>,
issues: Vec<RadrootsActiveOrderReducerIssue>,
) -> RadrootsActiveOrderProjection {
+ invalid_projection_with_payment(
+ order_id,
+ request,
+ issues,
+ RadrootsActiveOrderPaymentProjection::not_recorded(),
+ )
+}
+
+fn invalid_projection_with_payment(
+ order_id: &str,
+ request: Option<&RadrootsActiveOrderRequestRecord>,
+ issues: Vec<RadrootsActiveOrderReducerIssue>,
+ payment: RadrootsActiveOrderPaymentProjection,
+) -> RadrootsActiveOrderProjection {
let economics = match request {
Some(request) if request.payload.validate().is_ok() => {
Some(request.payload.economics.clone())
@@ -2681,8 +3521,7 @@ fn invalid_projection(
receipt_issue: None,
receipt_received_at: None,
lifecycle_terminal: true,
- settlement_pending: false,
- settlement_reason: None,
+ payment,
economics,
agreement_event_id: None,
listing_addr: request.map(|request| request.payload.listing_addr.clone()),
@@ -2849,20 +3688,25 @@ mod tests {
RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem,
RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision,
RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed,
- RadrootsTradePricingBasis,
+ RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradePricingBasis,
+ RadrootsTradeSettlementDecision, RadrootsTradeSettlementDecisionEvent,
};
use super::{
RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord,
- RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderProjection,
- RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderReducerIssue,
- RadrootsActiveOrderRequestRecord, RadrootsActiveOrderRevisionDecisionRecord,
- RadrootsActiveOrderRevisionProposalRecord, RadrootsActiveOrderStatus,
- RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection,
- RadrootsListingInventoryBinAccounting, RadrootsListingInventoryBinAvailability,
- RadrootsListingInventoryOrderReservation, RadrootsTradeOrderCanonicalizationError,
- add_inventory_reservation, canonicalize_active_order_decision_for_signer,
+ RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderPaymentProjection,
+ RadrootsActiveOrderPaymentRecord, RadrootsActiveOrderPaymentState,
+ RadrootsActiveOrderProjection, RadrootsActiveOrderReceiptRecord,
+ RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord,
+ RadrootsActiveOrderRevisionDecisionRecord, RadrootsActiveOrderRevisionProposalRecord,
+ RadrootsActiveOrderSettlementRecord, RadrootsActiveOrderSettlementState,
+ RadrootsActiveOrderStatus, RadrootsListingInventoryAccountingIssue,
+ RadrootsListingInventoryAccountingProjection, RadrootsListingInventoryBinAccounting,
+ RadrootsListingInventoryBinAvailability, RadrootsListingInventoryOrderReservation,
+ RadrootsTradeOrderCanonicalizationError, add_inventory_reservation,
+ canonicalize_active_order_decision_for_signer,
canonicalize_active_order_request_for_signer, canonicalize_order_request_for_signer,
+ radroots_trade_order_economics_digest,
reduce_active_order_events as reduce_active_order_events_with_revisions,
reduce_listing_inventory_accounting as reduce_listing_inventory_accounting_with_revisions,
};
@@ -3096,6 +3940,67 @@ mod tests {
}
}
+ fn payment_record(event_id: &str, prev_event_id: &str) -> RadrootsActiveOrderPaymentRecord {
+ let economics = request_economics("bin-1", 2, "10");
+ RadrootsActiveOrderPaymentRecord {
+ 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: RadrootsTradePaymentRecorded {
+ order_id: "order-1".to_string(),
+ listing_addr: listing_addr(),
+ buyer_pubkey: BUYER.to_string(),
+ seller_pubkey: SELLER.to_string(),
+ root_event_id: "request-1".to_string(),
+ previous_event_id: prev_event_id.to_string(),
+ agreement_event_id: "decision-1".to_string(),
+ quote_id: economics.quote_id.clone(),
+ quote_version: economics.quote_version,
+ economics_digest: radroots_trade_order_economics_digest(&economics).unwrap(),
+ amount: economics.total.amount,
+ currency: economics.total.currency,
+ method: RadrootsTradePaymentMethod::ManualTransfer,
+ reference: Some("manual reference".to_string()),
+ paid_at: Some(1_777_666_000),
+ },
+ }
+ }
+
+ fn settlement_record(
+ event_id: &str,
+ payment_event_id: &str,
+ decision: RadrootsTradeSettlementDecision,
+ ) -> RadrootsActiveOrderSettlementRecord {
+ let payment = payment_record(payment_event_id, "decision-1");
+ RadrootsActiveOrderSettlementRecord {
+ event_id: event_id.to_string(),
+ author_pubkey: SELLER.to_string(),
+ counterparty_pubkey: BUYER.to_string(),
+ root_event_id: "request-1".to_string(),
+ prev_event_id: payment_event_id.to_string(),
+ payload: RadrootsTradeSettlementDecisionEvent {
+ order_id: payment.payload.order_id,
+ listing_addr: payment.payload.listing_addr,
+ seller_pubkey: payment.payload.seller_pubkey,
+ buyer_pubkey: payment.payload.buyer_pubkey,
+ root_event_id: payment.payload.root_event_id,
+ previous_event_id: payment_event_id.to_string(),
+ agreement_event_id: payment.payload.agreement_event_id,
+ payment_event_id: payment_event_id.to_string(),
+ quote_id: payment.payload.quote_id,
+ quote_version: payment.payload.quote_version,
+ economics_digest: payment.payload.economics_digest,
+ amount: payment.payload.amount,
+ currency: payment.payload.currency,
+ decision,
+ reason: (decision == RadrootsTradeSettlementDecision::Rejected)
+ .then(|| "reference mismatch".to_string()),
+ },
+ }
+ }
+
fn accepted_decision_record_for(
order_id: &str,
event_id: &str,
@@ -3204,6 +4109,8 @@ mod tests {
fulfillments,
cancellations,
receipts,
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
)
}
@@ -3412,6 +4319,141 @@ mod tests {
}
#[test]
+ fn reduce_active_order_events_reports_recorded_payment_state() {
+ let projection = reduce_active_order_events_with_revisions(
+ "order-1",
+ [request_record()],
+ [accepted_decision_record("decision-1")],
+ Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(),
+ Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(),
+ Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
+ Vec::<RadrootsActiveOrderCancellationRecord>::new(),
+ Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ [payment_record("payment-1", "decision-1")],
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
+ );
+
+ assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted);
+ assert_eq!(
+ projection.payment.state,
+ RadrootsActiveOrderPaymentState::Recorded
+ );
+ assert_eq!(
+ projection.payment.settlement_state,
+ RadrootsActiveOrderSettlementState::Pending
+ );
+ assert_eq!(
+ projection.payment.payment_event_id.as_deref(),
+ Some("payment-1")
+ );
+ assert_eq!(
+ projection.payment.agreement_event_id.as_deref(),
+ Some("decision-1")
+ );
+ assert_eq!(projection.payment.amount, Some(decimal("10")));
+ assert_eq!(projection.payment.currency, Some(RadrootsCoreCurrency::USD));
+ assert!(projection.issues.is_empty());
+ }
+
+ #[test]
+ fn reduce_active_order_events_reports_accepted_settlement_state() {
+ let projection = reduce_active_order_events_with_revisions(
+ "order-1",
+ [request_record()],
+ [accepted_decision_record("decision-1")],
+ Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(),
+ Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(),
+ Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
+ Vec::<RadrootsActiveOrderCancellationRecord>::new(),
+ Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ [payment_record("payment-1", "decision-1")],
+ [settlement_record(
+ "settlement-1",
+ "payment-1",
+ RadrootsTradeSettlementDecision::Accepted,
+ )],
+ );
+
+ assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted);
+ assert_eq!(
+ projection.payment.state,
+ RadrootsActiveOrderPaymentState::Settled
+ );
+ assert_eq!(
+ projection.payment.settlement_state,
+ RadrootsActiveOrderSettlementState::Accepted
+ );
+ assert_eq!(
+ projection.payment.settlement_event_id.as_deref(),
+ Some("settlement-1")
+ );
+ assert_eq!(projection.payment.reason, None);
+ assert!(projection.issues.is_empty());
+ }
+
+ #[test]
+ fn reduce_active_order_events_rejects_stale_payment_amount() {
+ let mut payment = payment_record("payment-1", "decision-1");
+ payment.payload.amount = decimal("9");
+
+ let projection = reduce_active_order_events_with_revisions(
+ "order-1",
+ [request_record()],
+ [accepted_decision_record("decision-1")],
+ Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(),
+ Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(),
+ Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
+ Vec::<RadrootsActiveOrderCancellationRecord>::new(),
+ Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ [payment],
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
+ );
+
+ assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid);
+ assert_eq!(
+ projection.payment.state,
+ RadrootsActiveOrderPaymentState::Invalid
+ );
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsActiveOrderReducerIssue::PaymentAmountMismatch { event_id }
+ if event_id == "payment-1"
+ )));
+ }
+
+ #[test]
+ fn reduce_active_order_events_keeps_payment_separate_from_receipt() {
+ let projection = reduce_active_order_events_with_revisions(
+ "order-1",
+ [request_record()],
+ [accepted_decision_record("decision-1")],
+ Vec::<RadrootsActiveOrderRevisionProposalRecord>::new(),
+ Vec::<RadrootsActiveOrderRevisionDecisionRecord>::new(),
+ [fulfillment_record(
+ "fulfillment-1",
+ "decision-1",
+ RadrootsActiveTradeFulfillmentState::Delivered,
+ )],
+ Vec::<RadrootsActiveOrderCancellationRecord>::new(),
+ [receipt_record("receipt-1", "fulfillment-1", true)],
+ [payment_record("payment-1", "decision-1")],
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
+ );
+
+ assert_eq!(projection.status, RadrootsActiveOrderStatus::Completed);
+ assert_eq!(projection.receipt_received, Some(true));
+ assert_eq!(
+ projection.payment.state,
+ RadrootsActiveOrderPaymentState::Recorded
+ );
+ assert_eq!(
+ projection.payment.settlement_state,
+ RadrootsActiveOrderSettlementState::Pending
+ );
+ assert!(projection.issues.is_empty());
+ }
+
+ #[test]
fn reduce_active_order_events_applies_accepted_revision_agreement() {
let projection = reduce_active_order_events_with_revisions(
"order-1",
@@ -3432,6 +4474,8 @@ mod tests {
Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
Vec::<RadrootsActiveOrderCancellationRecord>::new(),
Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
);
assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted);
@@ -3473,6 +4517,8 @@ mod tests {
Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
Vec::<RadrootsActiveOrderCancellationRecord>::new(),
Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
);
assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted);
@@ -3512,6 +4558,8 @@ mod tests {
Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
Vec::<RadrootsActiveOrderCancellationRecord>::new(),
Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
);
assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid);
@@ -3543,6 +4591,8 @@ mod tests {
Vec::<RadrootsActiveOrderFulfillmentRecord>::new(),
Vec::<RadrootsActiveOrderCancellationRecord>::new(),
Vec::<RadrootsActiveOrderReceiptRecord>::new(),
+ Vec::<RadrootsActiveOrderPaymentRecord>::new(),
+ Vec::<RadrootsActiveOrderSettlementRecord>::new(),
);
assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid);
@@ -3646,10 +4696,9 @@ mod tests {
);
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")
+ projection.payment,
+ RadrootsActiveOrderPaymentProjection::not_recorded()
);
assert!(projection.issues.is_empty());
}
@@ -3744,7 +4793,10 @@ mod tests {
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);
+ assert_eq!(
+ projection.payment,
+ RadrootsActiveOrderPaymentProjection::not_recorded()
+ );
}
#[test]
@@ -3798,10 +4850,9 @@ mod tests {
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")
+ projection.payment,
+ RadrootsActiveOrderPaymentProjection::not_recorded()
);
}