1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::capability::scope::MonetaryAmount;
8use crate::credit::{SignedCreditBond, SignedCreditLossLifecycle, SignedExposureLedgerReport};
9use crate::receipt::lineage::SignedExportEnvelope;
10
11use crate::{validate_positive_money, verify_signed_artifact, SignedLiabilityBoundCoverage};
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum LiabilityClaimEvidenceKind {
16 BoundCoverage,
17 ExposureLedger,
18 CreditBond,
19 CreditLossLifecycle,
20 Receipt,
21 ClaimResponse,
22 ClaimDispute,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct LiabilityClaimEvidenceReference {
28 pub kind: LiabilityClaimEvidenceKind,
29 pub reference_id: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub observed_at: Option<u64>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub locator: Option<String>,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum LiabilityClaimResponseDisposition {
39 Acknowledged,
40 Accepted,
41 Denied,
42}
43
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum LiabilityClaimAdjudicationOutcome {
47 ClaimUpheld,
48 ProviderUpheld,
49 PartialSettlement,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(rename_all = "camelCase")]
54pub struct LiabilityClaimPackageArtifact {
55 pub schema: String,
56 pub claim_id: String,
57 pub issued_at: u64,
58 pub bound_coverage: SignedLiabilityBoundCoverage,
59 pub exposure: SignedExposureLedgerReport,
60 pub bond: SignedCreditBond,
61 pub loss_event: SignedCreditLossLifecycle,
62 pub claimant: String,
63 pub claim_event_at: u64,
64 pub claim_amount: MonetaryAmount,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub claim_ref: Option<String>,
67 pub narrative: String,
68 pub receipt_ids: Vec<String>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
71}
72
73impl LiabilityClaimPackageArtifact {
74 pub fn validate(&self) -> Result<(), String> {
75 verify_signed_artifact(&self.bound_coverage, "claim package bound_coverage")?;
76 verify_signed_artifact(&self.exposure, "claim package exposure")?;
77 verify_signed_artifact(&self.bond, "claim package bond")?;
78 verify_signed_artifact(&self.loss_event, "claim package loss_event")?;
79 if self.claimant.trim().is_empty() {
80 return Err("claim packages require a non-empty claimant".to_string());
81 }
82 if self.narrative.trim().is_empty() {
83 return Err("claim packages require a non-empty narrative".to_string());
84 }
85 if self.receipt_ids.is_empty() {
86 return Err("claim packages require at least one receipt reference".to_string());
87 }
88 let mut deduped_receipts = BTreeSet::new();
89 for receipt_id in &self.receipt_ids {
90 if receipt_id.trim().is_empty() {
91 return Err("claim receipt references must be non-empty".to_string());
92 }
93 if !deduped_receipts.insert(receipt_id.trim().to_string()) {
94 return Err("claim receipt references must be unique".to_string());
95 }
96 }
97 validate_positive_money(&self.claim_amount, "claim_amount")?;
98 let coverage = &self.bound_coverage.body.coverage_amount;
99 if self.claim_amount.currency != coverage.currency {
100 return Err("claim_amount currency must match bound coverage currency".to_string());
101 }
102 if self.claim_amount.units > coverage.units {
103 return Err("claim_amount cannot exceed bound coverage amount".to_string());
104 }
105 if self.claim_event_at < self.bound_coverage.body.effective_from
106 || self.claim_event_at > self.bound_coverage.body.effective_until
107 {
108 return Err(
109 "claim_event_at must fall within the bound coverage effective window".to_string(),
110 );
111 }
112 if self.exposure.body.summary.mixed_currency_book {
113 return Err(
114 "claim packages require exposure evidence without mixed-currency ambiguity"
115 .to_string(),
116 );
117 }
118 let subject_key = &self
119 .bound_coverage
120 .body
121 .placement
122 .body
123 .quote_response
124 .body
125 .quote_request
126 .body
127 .risk_package
128 .body
129 .subject_key;
130 if self
131 .exposure
132 .body
133 .filters
134 .agent_subject
135 .as_ref()
136 .is_some_and(|agent_subject| agent_subject != subject_key)
137 {
138 return Err(
139 "claim exposure evidence must match the bound coverage subject".to_string(),
140 );
141 }
142 if self
143 .bond
144 .body
145 .report
146 .filters
147 .agent_subject
148 .as_ref()
149 .is_some_and(|agent_subject| agent_subject != subject_key)
150 {
151 return Err("claim bond evidence must match the bound coverage subject".to_string());
152 }
153 if self.loss_event.body.bond_id != self.bond.body.bond_id {
154 return Err("claim loss evidence must reference the same bond".to_string());
155 }
156 if self
157 .loss_event
158 .body
159 .report
160 .summary
161 .agent_subject
162 .as_ref()
163 .is_some_and(|agent_subject| agent_subject != subject_key)
164 {
165 return Err("claim loss evidence must match the bound coverage subject".to_string());
166 }
167 Ok(())
168 }
169}
170
171pub type SignedLiabilityClaimPackage = SignedExportEnvelope<LiabilityClaimPackageArtifact>;
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(rename_all = "camelCase")]
175pub struct LiabilityClaimResponseArtifact {
176 pub schema: String,
177 pub claim_response_id: String,
178 pub issued_at: u64,
179 pub claim: SignedLiabilityClaimPackage,
180 pub provider_response_ref: String,
181 pub disposition: LiabilityClaimResponseDisposition,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub covered_amount: Option<MonetaryAmount>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub response_note: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub denial_reason: Option<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
190}
191
192impl LiabilityClaimResponseArtifact {
193 pub fn validate(&self) -> Result<(), String> {
194 verify_signed_artifact(&self.claim, "claim response claim")?;
195 self.claim.body.validate()?;
196 if self.provider_response_ref.trim().is_empty() {
197 return Err("claim responses require a non-empty provider_response_ref".to_string());
198 }
199 match self.disposition {
200 LiabilityClaimResponseDisposition::Acknowledged => {
201 if self.covered_amount.is_some() {
202 return Err(
203 "acknowledged claim responses cannot include covered_amount".to_string()
204 );
205 }
206 if self.denial_reason.is_some() {
207 return Err(
208 "acknowledged claim responses cannot include denial_reason".to_string()
209 );
210 }
211 }
212 LiabilityClaimResponseDisposition::Accepted => {
213 let covered_amount = self
214 .covered_amount
215 .as_ref()
216 .ok_or_else(|| "accepted claim responses require covered_amount".to_string())?;
217 validate_positive_money(covered_amount, "covered_amount")?;
218 if covered_amount.currency != self.claim.body.claim_amount.currency {
219 return Err(
220 "covered_amount currency must match claim_amount currency".to_string()
221 );
222 }
223 if covered_amount.units > self.claim.body.claim_amount.units {
224 return Err("covered_amount cannot exceed claim_amount".to_string());
225 }
226 if self.denial_reason.is_some() {
227 return Err("accepted claim responses cannot include denial_reason".to_string());
228 }
229 }
230 LiabilityClaimResponseDisposition::Denied => {
231 if self.covered_amount.is_some() {
232 return Err("denied claim responses cannot include covered_amount".to_string());
233 }
234 if self
235 .denial_reason
236 .as_ref()
237 .is_none_or(|reason| reason.trim().is_empty())
238 {
239 return Err("denied claim responses require denial_reason".to_string());
240 }
241 }
242 }
243 Ok(())
244 }
245}
246
247pub type SignedLiabilityClaimResponse = SignedExportEnvelope<LiabilityClaimResponseArtifact>;
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250#[serde(rename_all = "camelCase")]
251pub struct LiabilityClaimDisputeArtifact {
252 pub schema: String,
253 pub dispute_id: String,
254 pub issued_at: u64,
255 pub provider_response: SignedLiabilityClaimResponse,
256 pub opened_by: String,
257 pub reason: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub note: Option<String>,
260 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
262}
263
264impl LiabilityClaimDisputeArtifact {
265 pub fn validate(&self) -> Result<(), String> {
266 verify_signed_artifact(&self.provider_response, "claim dispute provider_response")?;
267 self.provider_response.body.validate()?;
268 if self.opened_by.trim().is_empty() {
269 return Err("claim disputes require a non-empty opened_by".to_string());
270 }
271 if self.reason.trim().is_empty() {
272 return Err("claim disputes require a non-empty reason".to_string());
273 }
274 let partially_accepted = self.provider_response.body.disposition
275 == LiabilityClaimResponseDisposition::Accepted
276 && self
277 .provider_response
278 .body
279 .covered_amount
280 .as_ref()
281 .is_some_and(|amount| {
282 amount.units < self.provider_response.body.claim.body.claim_amount.units
283 });
284 if self.provider_response.body.disposition != LiabilityClaimResponseDisposition::Denied
285 && !partially_accepted
286 {
287 return Err(
288 "claim disputes require a denied or partially accepted provider response"
289 .to_string(),
290 );
291 }
292 Ok(())
293 }
294}
295
296pub type SignedLiabilityClaimDispute = SignedExportEnvelope<LiabilityClaimDisputeArtifact>;
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
299#[serde(rename_all = "camelCase")]
300pub struct LiabilityClaimAdjudicationArtifact {
301 pub schema: String,
302 pub adjudication_id: String,
303 pub issued_at: u64,
304 pub dispute: SignedLiabilityClaimDispute,
305 pub adjudicator: String,
306 pub outcome: LiabilityClaimAdjudicationOutcome,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub awarded_amount: Option<MonetaryAmount>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub note: Option<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub decision_rule_ref: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub roster_anchor_ref: Option<String>,
321 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
323}
324
325impl LiabilityClaimAdjudicationArtifact {
326 pub fn validate(&self) -> Result<(), String> {
327 verify_signed_artifact(&self.dispute, "claim adjudication dispute")?;
328 self.dispute.body.validate()?;
329 if self.adjudicator.trim().is_empty() {
330 return Err("claim adjudications require a non-empty adjudicator".to_string());
331 }
332 if self
333 .decision_rule_ref
334 .as_ref()
335 .is_some_and(|rule| rule.trim().is_empty())
336 {
337 return Err(
338 "claim adjudication decision_rule_ref must not be blank when present".to_string(),
339 );
340 }
341 if self
342 .roster_anchor_ref
343 .as_ref()
344 .is_some_and(|anchor| anchor.trim().is_empty())
345 {
346 return Err(
347 "claim adjudication roster_anchor_ref must not be blank when present".to_string(),
348 );
349 }
350 let claim_amount = &self
351 .dispute
352 .body
353 .provider_response
354 .body
355 .claim
356 .body
357 .claim_amount;
358 match self.outcome {
359 LiabilityClaimAdjudicationOutcome::ClaimUpheld => {
360 let awarded_amount = self.awarded_amount.as_ref().ok_or_else(|| {
361 "claim_upheld adjudications require awarded_amount".to_string()
362 })?;
363 validate_positive_money(awarded_amount, "awarded_amount")?;
364 if awarded_amount.currency != claim_amount.currency {
365 return Err(
366 "awarded_amount currency must match claim_amount currency".to_string()
367 );
368 }
369 if awarded_amount.units > claim_amount.units {
370 return Err("awarded_amount cannot exceed claim_amount".to_string());
371 }
372 }
373 LiabilityClaimAdjudicationOutcome::ProviderUpheld => {
374 if self.awarded_amount.is_some() {
375 return Err(
376 "provider_upheld adjudications cannot include awarded_amount".to_string(),
377 );
378 }
379 }
380 LiabilityClaimAdjudicationOutcome::PartialSettlement => {
381 let awarded_amount = self.awarded_amount.as_ref().ok_or_else(|| {
382 "partial_settlement adjudications require awarded_amount".to_string()
383 })?;
384 validate_positive_money(awarded_amount, "awarded_amount")?;
385 if awarded_amount.currency != claim_amount.currency {
386 return Err(
387 "awarded_amount currency must match claim_amount currency".to_string()
388 );
389 }
390 if awarded_amount.units >= claim_amount.units {
391 return Err(
392 "partial_settlement awarded_amount must be less than claim_amount"
393 .to_string(),
394 );
395 }
396 }
397 }
398 Ok(())
399 }
400
401 pub fn validate_against_roster(
410 &self,
411 roster: &[String],
412 allowed_decision_rules: &[String],
413 roster_anchor: &str,
414 ) -> Result<(), String> {
415 let adjudicator = self.adjudicator.trim();
416 if !roster.iter().any(|entry| entry.trim() == adjudicator) {
417 return Err(format!(
418 "adjudicator \"{adjudicator}\" is not on the predeclared roster"
419 ));
420 }
421 let rule = self
422 .decision_rule_ref
423 .as_ref()
424 .map(|rule| rule.trim())
425 .filter(|rule| !rule.is_empty())
426 .ok_or_else(|| {
427 "adjudication is missing a decision_rule_ref (ADR-0015 follow-up B)".to_string()
428 })?;
429 if !allowed_decision_rules
430 .iter()
431 .any(|allowed| allowed.trim() == rule)
432 {
433 return Err(format!(
434 "decision_rule_ref \"{rule}\" is not an allowed decision rule"
435 ));
436 }
437 let recorded_anchor = self
438 .roster_anchor_ref
439 .as_ref()
440 .map(|anchor| anchor.trim())
441 .filter(|anchor| !anchor.is_empty())
442 .ok_or_else(|| {
443 "adjudication is missing a roster_anchor_ref (ADR-0015 follow-up B)".to_string()
444 })?;
445 if recorded_anchor != roster_anchor.trim() {
446 return Err(format!(
447 "roster_anchor_ref \"{recorded_anchor}\" does not match the applied roster anchor \"{}\"",
448 roster_anchor.trim()
449 ));
450 }
451 Ok(())
452 }
453}
454
455pub type SignedLiabilityClaimAdjudication =
456 SignedExportEnvelope<LiabilityClaimAdjudicationArtifact>;