1use serde::{Deserialize, Serialize};
4
5use crate::capability::scope::MonetaryAmount;
6use crate::credit::{
7 validate_capital_execution_authority_step_proof, CapitalBookSourceKind,
8 CapitalExecutionAuthorityStep, CapitalExecutionInstructionAction, CapitalExecutionObservation,
9 CapitalExecutionRail, CapitalExecutionReconciledState, CapitalExecutionRole,
10 CapitalExecutionWindow, SignedCapitalBookReport, SignedCapitalExecutionInstruction,
11};
12use crate::receipt::lineage::SignedExportEnvelope;
13
14use crate::{
15 bounded_market_query_limit, liability_claim_adjudication_payable_amount,
16 validate_positive_money, verify_signed_artifact, SignedLiabilityClaimAdjudication,
17 SignedLiabilityClaimDispute, SignedLiabilityClaimPackage, SignedLiabilityClaimResponse,
18 MAX_LIABILITY_CLAIM_WORKFLOW_LIMIT,
19};
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum LiabilityClaimPayoutReconciliationState {
24 Matched,
25 AmountMismatch,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum LiabilityClaimSettlementKind {
31 RecoveryClearing,
32 ReinsuranceReimbursement,
33 FacilityReimbursement,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum LiabilityClaimSettlementReconciliationState {
39 Matched,
40 AmountMismatch,
41 CounterpartyMismatch,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "camelCase")]
46pub struct LiabilityClaimSettlementRoleBinding {
47 pub role: CapitalExecutionRole,
48 pub party_id: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub jurisdiction: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub note: Option<String>,
53}
54
55impl LiabilityClaimSettlementRoleBinding {
56 fn validate(&self, field_name: &str) -> Result<(), String> {
57 if self.party_id.trim().is_empty() {
58 return Err(format!("{field_name} requires a non-empty party_id"));
59 }
60 Ok(())
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "camelCase")]
66pub struct LiabilityClaimSettlementRoleTopology {
67 pub payer: LiabilityClaimSettlementRoleBinding,
68 pub payee: LiabilityClaimSettlementRoleBinding,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub beneficiary: Option<LiabilityClaimSettlementRoleBinding>,
71}
72
73impl LiabilityClaimSettlementRoleTopology {
74 fn validate(&self) -> Result<(), String> {
75 self.payer.validate("settlement topology payer")?;
76 self.payee.validate("settlement topology payee")?;
77 if self.payer.role == self.payee.role && self.payer.party_id == self.payee.party_id {
78 return Err("settlement topology payer and payee must not be identical".to_string());
79 }
80 if let Some(beneficiary) = self.beneficiary.as_ref() {
81 beneficiary.validate("settlement topology beneficiary")?;
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88#[serde(rename_all = "camelCase")]
89pub struct LiabilityClaimPayoutInstructionArtifact {
90 pub schema: String,
91 pub payout_instruction_id: String,
92 pub issued_at: u64,
93 pub adjudication: SignedLiabilityClaimAdjudication,
94 pub capital_instruction: SignedCapitalExecutionInstruction,
95 pub payout_amount: MonetaryAmount,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub note: Option<String>,
98}
99
100impl LiabilityClaimPayoutInstructionArtifact {
101 pub fn validate(&self) -> Result<(), String> {
102 verify_signed_artifact(&self.adjudication, "claim payout instruction adjudication")?;
103 self.adjudication.body.validate()?;
104 if !self
105 .capital_instruction
106 .verify_signature()
107 .map_err(|error| error.to_string())?
108 {
109 return Err(
110 "claim payout instruction capital_instruction signature verification failed"
111 .to_string(),
112 );
113 }
114 validate_positive_money(&self.payout_amount, "payout_amount")?;
115 let awarded_amount = liability_claim_adjudication_payable_amount(&self.adjudication.body)?;
116 if &self.payout_amount != awarded_amount {
117 return Err(
118 "claim payout instruction payout_amount must match adjudication awarded_amount"
119 .to_string(),
120 );
121 }
122 let capital_instruction = &self.capital_instruction.body;
123 if capital_instruction.action != CapitalExecutionInstructionAction::TransferFunds {
124 return Err(
125 "claim payout instructions require capital_instruction action transfer_funds"
126 .to_string(),
127 );
128 }
129 if capital_instruction.source_kind != CapitalBookSourceKind::FacilityCommitment {
130 return Err(
131 "claim payout instructions require capital_instruction source_kind facility_commitment"
132 .to_string(),
133 );
134 }
135 let intended_amount = capital_instruction.amount.as_ref().ok_or_else(|| {
136 "claim payout instructions require capital_instruction amount".to_string()
137 })?;
138 if intended_amount != &self.payout_amount {
139 return Err(
140 "claim payout instruction capital_instruction amount must match payout_amount"
141 .to_string(),
142 );
143 }
144 let subject_key = &self
145 .adjudication
146 .body
147 .dispute
148 .body
149 .provider_response
150 .body
151 .claim
152 .body
153 .bound_coverage
154 .body
155 .placement
156 .body
157 .quote_response
158 .body
159 .quote_request
160 .body
161 .risk_package
162 .body
163 .subject_key;
164 if &capital_instruction.subject_key != subject_key {
165 return Err(
166 "claim payout instruction capital_instruction subject_key must match the claim subject"
167 .to_string(),
168 );
169 }
170 if capital_instruction.execution_window.not_after <= self.issued_at {
171 return Err(
172 "claim payout instructions require a non-stale capital_instruction execution window"
173 .to_string(),
174 );
175 }
176 if capital_instruction.reconciled_state != CapitalExecutionReconciledState::NotObserved
177 || capital_instruction.observed_execution.is_some()
178 {
179 return Err(
180 "claim payout instructions require an unreconciled capital_instruction so payout receipts stay explicit"
181 .to_string(),
182 );
183 }
184 Ok(())
185 }
186}
187
188pub type SignedLiabilityClaimPayoutInstruction =
189 SignedExportEnvelope<LiabilityClaimPayoutInstructionArtifact>;
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
192#[serde(rename_all = "camelCase")]
193pub struct LiabilityClaimPayoutReceiptArtifact {
194 pub schema: String,
195 pub payout_receipt_id: String,
196 pub issued_at: u64,
197 pub payout_instruction: SignedLiabilityClaimPayoutInstruction,
198 pub payout_receipt_ref: String,
199 pub reconciliation_state: LiabilityClaimPayoutReconciliationState,
200 pub observed_execution: crate::credit::CapitalExecutionObservation,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub note: Option<String>,
203}
204
205impl LiabilityClaimPayoutReceiptArtifact {
206 pub fn validate(&self) -> Result<(), String> {
207 verify_signed_artifact(
208 &self.payout_instruction,
209 "claim payout receipt payout_instruction",
210 )?;
211 self.payout_instruction.body.validate()?;
212 if self.payout_receipt_ref.trim().is_empty() {
213 return Err("claim payout receipts require a non-empty payout_receipt_ref".to_string());
214 }
215 if self
216 .observed_execution
217 .external_reference_id
218 .trim()
219 .is_empty()
220 {
221 return Err(
222 "claim payout receipts require a non-empty observed_execution external_reference_id"
223 .to_string(),
224 );
225 }
226 validate_positive_money(
227 &self.observed_execution.amount,
228 "claim payout receipt observed_execution amount",
229 )?;
230 if self.observed_execution.amount.currency
231 != self.payout_instruction.body.payout_amount.currency
232 {
233 return Err(
234 "claim payout receipt observed_execution amount currency must match payout_amount"
235 .to_string(),
236 );
237 }
238 let execution_window = &self
239 .payout_instruction
240 .body
241 .capital_instruction
242 .body
243 .execution_window;
244 if self.observed_execution.observed_at < execution_window.not_before
245 || self.observed_execution.observed_at > execution_window.not_after
246 {
247 return Err(
248 "claim payout receipt observed_execution timestamp falls outside the payout instruction execution window"
249 .to_string(),
250 );
251 }
252 match self.reconciliation_state {
253 LiabilityClaimPayoutReconciliationState::Matched => {
254 if self.observed_execution.amount != self.payout_instruction.body.payout_amount {
255 return Err(
256 "matched claim payout receipts require observed_execution amount to match payout_amount"
257 .to_string(),
258 );
259 }
260 }
261 LiabilityClaimPayoutReconciliationState::AmountMismatch => {
262 if self.observed_execution.amount == self.payout_instruction.body.payout_amount {
263 return Err(
264 "amount_mismatch claim payout receipts require observed_execution amount to differ from payout_amount"
265 .to_string(),
266 );
267 }
268 }
269 }
270 Ok(())
271 }
272}
273
274pub type SignedLiabilityClaimPayoutReceipt =
275 SignedExportEnvelope<LiabilityClaimPayoutReceiptArtifact>;
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
278#[serde(rename_all = "camelCase")]
279pub struct LiabilityClaimSettlementInstructionArtifact {
280 pub schema: String,
281 pub settlement_instruction_id: String,
282 pub issued_at: u64,
283 pub payout_receipt: SignedLiabilityClaimPayoutReceipt,
284 pub capital_book: SignedCapitalBookReport,
285 pub settlement_kind: LiabilityClaimSettlementKind,
286 pub settlement_amount: MonetaryAmount,
287 pub topology: LiabilityClaimSettlementRoleTopology,
288 pub authority_chain: Vec<CapitalExecutionAuthorityStep>,
289 pub execution_window: CapitalExecutionWindow,
290 pub rail: CapitalExecutionRail,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub settlement_reference: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub note: Option<String>,
295}
296
297impl LiabilityClaimSettlementInstructionArtifact {
298 pub fn validate(&self) -> Result<(), String> {
299 verify_signed_artifact(
300 &self.payout_receipt,
301 "claim settlement instruction payout_receipt",
302 )?;
303 self.payout_receipt.body.validate()?;
304 if !self
305 .capital_book
306 .verify_signature()
307 .map_err(|error| error.to_string())?
308 {
309 return Err(
310 "claim settlement instruction capital_book signature verification failed"
311 .to_string(),
312 );
313 }
314 validate_positive_money(&self.settlement_amount, "settlement_amount")?;
315 self.topology.validate()?;
316 if self.payout_receipt.body.reconciliation_state
317 != LiabilityClaimPayoutReconciliationState::Matched
318 {
319 return Err(
320 "claim settlement instructions require a matched payout_receipt".to_string(),
321 );
322 }
323 if self.settlement_amount.currency
324 != self
325 .payout_receipt
326 .body
327 .payout_instruction
328 .body
329 .payout_amount
330 .currency
331 {
332 return Err(
333 "claim settlement instruction settlement_amount currency must match payout_amount"
334 .to_string(),
335 );
336 }
337 if self.settlement_amount.units
338 > self
339 .payout_receipt
340 .body
341 .payout_instruction
342 .body
343 .payout_amount
344 .units
345 {
346 return Err(
347 "claim settlement instruction settlement_amount cannot exceed payout_amount"
348 .to_string(),
349 );
350 }
351 let subject_key = &self
352 .payout_receipt
353 .body
354 .payout_instruction
355 .body
356 .adjudication
357 .body
358 .dispute
359 .body
360 .provider_response
361 .body
362 .claim
363 .body
364 .bound_coverage
365 .body
366 .placement
367 .body
368 .quote_response
369 .body
370 .quote_request
371 .body
372 .risk_package
373 .body
374 .subject_key;
375 if self.capital_book.body.subject_key != *subject_key {
376 return Err(
377 "claim settlement instruction capital_book subject_key must match the claim subject"
378 .to_string(),
379 );
380 }
381 if self.capital_book.body.summary.mixed_currency_book {
382 return Err(
383 "claim settlement instructions require a capital_book without mixed-currency ambiguity"
384 .to_string(),
385 );
386 }
387 if self.authority_chain.is_empty() {
388 return Err(
389 "claim settlement instructions require at least one authority_chain step"
390 .to_string(),
391 );
392 }
393 if self.rail.rail_id.trim().is_empty() {
394 return Err("claim settlement instructions require rail.rail_id".to_string());
395 }
396 if self.rail.custody_provider_id.trim().is_empty() {
397 return Err(
398 "claim settlement instructions require rail.custody_provider_id".to_string(),
399 );
400 }
401 if self.execution_window.not_before > self.execution_window.not_after {
402 return Err(
403 "claim settlement instructions require execution_window.not_before <= not_after"
404 .to_string(),
405 );
406 }
407 if self.execution_window.not_after <= self.issued_at {
408 return Err(
409 "claim settlement instructions require a non-stale execution_window".to_string(),
410 );
411 }
412 let mut payer_role_present = false;
413 let mut custodian_present = false;
414 for step in &self.authority_chain {
415 if step.principal_id.trim().is_empty() {
416 return Err(
417 "claim settlement authority_chain principal_id cannot be empty".to_string(),
418 );
419 }
420 validate_capital_execution_authority_step_proof(step)?;
421 if step.approved_at > step.expires_at {
422 return Err(
423 "claim settlement authority_chain requires approved_at <= expires_at"
424 .to_string(),
425 );
426 }
427 if step.expires_at < self.issued_at {
428 return Err(format!(
429 "claim settlement authority step `{}` is stale at issuance time",
430 step.principal_id
431 ));
432 }
433 if step.expires_at < self.execution_window.not_after {
434 return Err(format!(
435 "claim settlement authority step `{}` expires before the execution window closes",
436 step.principal_id
437 ));
438 }
439 if step.role == self.topology.payer.role {
440 payer_role_present = true;
441 }
442 if step.role == CapitalExecutionRole::Custodian
443 && step.principal_id == self.rail.custody_provider_id
444 {
445 custodian_present = true;
446 }
447 }
448 if !payer_role_present {
449 return Err(
450 "claim settlement authority_chain is missing payer-role approval".to_string(),
451 );
452 }
453 if !custodian_present {
454 return Err(
455 "claim settlement authority_chain is missing the custody-provider execution step"
456 .to_string(),
457 );
458 }
459 Ok(())
460 }
461}
462
463pub type SignedLiabilityClaimSettlementInstruction =
464 SignedExportEnvelope<LiabilityClaimSettlementInstructionArtifact>;
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467#[serde(rename_all = "camelCase")]
468pub struct LiabilityClaimSettlementReceiptArtifact {
469 pub schema: String,
470 pub settlement_receipt_id: String,
471 pub issued_at: u64,
472 pub settlement_instruction: SignedLiabilityClaimSettlementInstruction,
473 pub settlement_receipt_ref: String,
474 pub reconciliation_state: LiabilityClaimSettlementReconciliationState,
475 pub observed_execution: CapitalExecutionObservation,
476 pub observed_payer_id: String,
477 pub observed_payee_id: String,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub note: Option<String>,
480}
481
482impl LiabilityClaimSettlementReceiptArtifact {
483 pub fn validate(&self) -> Result<(), String> {
484 verify_signed_artifact(
485 &self.settlement_instruction,
486 "claim settlement receipt settlement_instruction",
487 )?;
488 self.settlement_instruction.body.validate()?;
489 if self.settlement_receipt_ref.trim().is_empty() {
490 return Err(
491 "claim settlement receipts require a non-empty settlement_receipt_ref".to_string(),
492 );
493 }
494 if self.observed_payer_id.trim().is_empty() {
495 return Err(
496 "claim settlement receipts require a non-empty observed_payer_id".to_string(),
497 );
498 }
499 if self.observed_payee_id.trim().is_empty() {
500 return Err(
501 "claim settlement receipts require a non-empty observed_payee_id".to_string(),
502 );
503 }
504 if self
505 .observed_execution
506 .external_reference_id
507 .trim()
508 .is_empty()
509 {
510 return Err(
511 "claim settlement receipts require a non-empty observed_execution external_reference_id"
512 .to_string(),
513 );
514 }
515 validate_positive_money(
516 &self.observed_execution.amount,
517 "claim settlement receipt observed_execution amount",
518 )?;
519 if self.observed_execution.amount.currency
520 != self.settlement_instruction.body.settlement_amount.currency
521 {
522 return Err(
523 "claim settlement receipt observed_execution amount currency must match settlement_amount"
524 .to_string(),
525 );
526 }
527 let execution_window = &self.settlement_instruction.body.execution_window;
528 if self.observed_execution.observed_at < execution_window.not_before
529 || self.observed_execution.observed_at > execution_window.not_after
530 {
531 return Err(
532 "claim settlement receipt observed_execution timestamp falls outside the settlement execution window"
533 .to_string(),
534 );
535 }
536 let expected_payer = &self.settlement_instruction.body.topology.payer.party_id;
537 let expected_payee = &self.settlement_instruction.body.topology.payee.party_id;
538 match self.reconciliation_state {
539 LiabilityClaimSettlementReconciliationState::Matched => {
540 if self.observed_execution.amount
541 != self.settlement_instruction.body.settlement_amount
542 {
543 return Err(
544 "matched claim settlement receipts require observed_execution amount to match settlement_amount"
545 .to_string(),
546 );
547 }
548 if &self.observed_payer_id != expected_payer
549 || &self.observed_payee_id != expected_payee
550 {
551 return Err(
552 "matched claim settlement receipts require observed payer/payee to match the settlement topology"
553 .to_string(),
554 );
555 }
556 }
557 LiabilityClaimSettlementReconciliationState::AmountMismatch => {
558 if self.observed_execution.amount
559 == self.settlement_instruction.body.settlement_amount
560 {
561 return Err(
562 "amount_mismatch claim settlement receipts require observed_execution amount to differ from settlement_amount"
563 .to_string(),
564 );
565 }
566 if &self.observed_payer_id != expected_payer
567 || &self.observed_payee_id != expected_payee
568 {
569 return Err(
570 "amount_mismatch claim settlement receipts still require observed payer/payee to match the settlement topology"
571 .to_string(),
572 );
573 }
574 }
575 LiabilityClaimSettlementReconciliationState::CounterpartyMismatch => {
576 if &self.observed_payer_id == expected_payer
577 && &self.observed_payee_id == expected_payee
578 {
579 return Err(
580 "counterparty_mismatch claim settlement receipts require at least one observed counterparty to differ from the settlement topology"
581 .to_string(),
582 );
583 }
584 }
585 }
586 Ok(())
587 }
588}
589
590pub type SignedLiabilityClaimSettlementReceipt =
591 SignedExportEnvelope<LiabilityClaimSettlementReceiptArtifact>;
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
594#[serde(rename_all = "camelCase")]
595pub struct LiabilityClaimWorkflowQuery {
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub claim_id: Option<String>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub provider_id: Option<String>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub agent_subject: Option<String>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub jurisdiction: Option<String>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub policy_number: Option<String>,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub limit: Option<usize>,
608}
609
610impl Default for LiabilityClaimWorkflowQuery {
611 fn default() -> Self {
612 Self {
613 claim_id: None,
614 provider_id: None,
615 agent_subject: None,
616 jurisdiction: None,
617 policy_number: None,
618 limit: Some(50),
619 }
620 }
621}
622
623impl LiabilityClaimWorkflowQuery {
624 #[must_use]
625 pub fn limit_or_default(&self) -> usize {
626 bounded_market_query_limit(self.limit, MAX_LIABILITY_CLAIM_WORKFLOW_LIMIT)
627 }
628
629 #[must_use]
630 pub fn normalized(&self) -> Self {
631 let mut normalized = self.clone();
632 normalized.limit = Some(self.limit_or_default());
633 normalized.claim_id = self.claim_id.as_ref().map(|value| value.trim().to_string());
634 normalized.provider_id = self
635 .provider_id
636 .as_ref()
637 .map(|value| value.trim().to_string());
638 normalized.agent_subject = self
639 .agent_subject
640 .as_ref()
641 .map(|value| value.trim().to_string());
642 normalized.jurisdiction = self
643 .jurisdiction
644 .as_ref()
645 .map(|value| value.trim().to_ascii_lowercase());
646 normalized.policy_number = self
647 .policy_number
648 .as_ref()
649 .map(|value| value.trim().to_string());
650 normalized
651 }
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
655#[serde(rename_all = "camelCase")]
656pub struct LiabilityClaimWorkflowRow {
657 pub claim: SignedLiabilityClaimPackage,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub provider_response: Option<SignedLiabilityClaimResponse>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub dispute: Option<SignedLiabilityClaimDispute>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub adjudication: Option<SignedLiabilityClaimAdjudication>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub payout_instruction: Option<SignedLiabilityClaimPayoutInstruction>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub payout_receipt: Option<SignedLiabilityClaimPayoutReceipt>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub settlement_instruction: Option<SignedLiabilityClaimSettlementInstruction>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub settlement_receipt: Option<SignedLiabilityClaimSettlementReceipt>,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
675#[serde(rename_all = "camelCase")]
676pub struct LiabilityClaimWorkflowSummary {
677 pub matching_claims: u64,
678 pub returned_claims: u64,
679 pub provider_responses: u64,
680 pub accepted_responses: u64,
681 pub denied_responses: u64,
682 pub disputes: u64,
683 pub adjudications: u64,
684 pub payout_instructions: u64,
685 pub payout_receipts: u64,
686 pub matched_payout_receipts: u64,
687 pub mismatched_payout_receipts: u64,
688 pub settlement_instructions: u64,
689 pub settlement_receipts: u64,
690 pub matched_settlement_receipts: u64,
691 pub mismatched_settlement_receipts: u64,
692 pub counterparty_mismatch_settlement_receipts: u64,
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
696#[serde(rename_all = "camelCase")]
697pub struct LiabilityClaimWorkflowReport {
698 pub schema: String,
699 pub generated_at: u64,
700 pub query: LiabilityClaimWorkflowQuery,
701 pub summary: LiabilityClaimWorkflowSummary,
702 pub claims: Vec<LiabilityClaimWorkflowRow>,
703}