Skip to main content

chio_market/
placement.rs

1//! Liability placement, bound-coverage, and auto-bind artifacts.
2
3use serde::{Deserialize, Serialize};
4
5use crate::capability::scope::MonetaryAmount;
6use crate::receipt::lineage::SignedExportEnvelope;
7
8use crate::{
9    validate_positive_money, LiabilityQuoteDisposition, SignedLiabilityPricingAuthority,
10    SignedLiabilityQuoteResponse,
11};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct LiabilityPlacementArtifact {
16    pub schema: String,
17    pub placement_id: String,
18    pub issued_at: u64,
19    pub quote_response: SignedLiabilityQuoteResponse,
20    pub selected_coverage_amount: MonetaryAmount,
21    pub selected_premium_amount: MonetaryAmount,
22    pub effective_from: u64,
23    pub effective_until: u64,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub placement_ref: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub notes: Option<String>,
28}
29
30impl LiabilityPlacementArtifact {
31    pub fn validate(&self) -> Result<(), String> {
32        if !self.quote_response.verify_signature().map_err(|error| {
33            format!("placement quote_response signature verification failed: {error}")
34        })? {
35            return Err("placement quote_response signature verification failed".to_string());
36        }
37        self.quote_response.body.validate()?;
38        let quote_request = &self.quote_response.body.quote_request.body;
39        let quoted_terms = self
40            .quote_response
41            .body
42            .quoted_terms
43            .as_ref()
44            .ok_or_else(|| "placements require a quoted quote response".to_string())?;
45        if self.quote_response.body.disposition != LiabilityQuoteDisposition::Quoted {
46            return Err("placements require a quoted quote response".to_string());
47        }
48        validate_positive_money(
49            &self.selected_coverage_amount,
50            "placement selected_coverage_amount",
51        )?;
52        validate_positive_money(
53            &self.selected_premium_amount,
54            "placement selected_premium_amount",
55        )?;
56        if self.selected_coverage_amount != quote_request.requested_coverage_amount {
57            return Err(
58                "placement selected_coverage_amount must match the quote request requested_coverage_amount"
59                    .to_string(),
60            );
61        }
62        if self.selected_coverage_amount != quoted_terms.quoted_coverage_amount {
63            return Err(
64                "placement selected_coverage_amount must match the quoted coverage amount"
65                    .to_string(),
66            );
67        }
68        if self.selected_premium_amount != quoted_terms.quoted_premium_amount {
69            return Err(
70                "placement selected_premium_amount must match the quoted premium amount"
71                    .to_string(),
72            );
73        }
74        if self.effective_from != quote_request.requested_effective_from
75            || self.effective_until != quote_request.requested_effective_until
76        {
77            return Err(
78                "placement effective window must match the quote request effective window"
79                    .to_string(),
80            );
81        }
82        if self.effective_until <= self.effective_from {
83            return Err("placement effective window must have end after start".to_string());
84        }
85        if self.issued_at >= quoted_terms.expires_at {
86            return Err("placement cannot be issued after the quote expires".to_string());
87        }
88        Ok(())
89    }
90}
91
92pub type SignedLiabilityPlacement = SignedExportEnvelope<LiabilityPlacementArtifact>;
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95#[serde(rename_all = "camelCase")]
96pub struct LiabilityBoundCoverageArtifact {
97    pub schema: String,
98    pub bound_coverage_id: String,
99    pub issued_at: u64,
100    pub placement: SignedLiabilityPlacement,
101    pub policy_number: String,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub carrier_reference: Option<String>,
104    pub bound_at: u64,
105    pub effective_from: u64,
106    pub effective_until: u64,
107    pub coverage_amount: MonetaryAmount,
108    pub premium_amount: MonetaryAmount,
109}
110
111impl LiabilityBoundCoverageArtifact {
112    pub fn validate(&self) -> Result<(), String> {
113        if !self.placement.verify_signature().map_err(|error| {
114            format!("bound coverage placement signature verification failed: {error}")
115        })? {
116            return Err("bound coverage placement signature verification failed".to_string());
117        }
118        self.placement.body.validate()?;
119        let quote_request = &self.placement.body.quote_response.body.quote_request.body;
120        if self.policy_number.trim().is_empty() {
121            return Err("bound coverage requires policy_number".to_string());
122        }
123        if self.bound_at < self.placement.body.issued_at {
124            return Err("bound coverage bound_at cannot precede placement issuance".to_string());
125        }
126        if self.effective_from != self.placement.body.effective_from
127            || self.effective_until != self.placement.body.effective_until
128        {
129            return Err(
130                "bound coverage effective window must match the placement effective window"
131                    .to_string(),
132            );
133        }
134        if self.effective_until <= self.effective_from {
135            return Err("bound coverage effective window must have end after start".to_string());
136        }
137        if self.coverage_amount != self.placement.body.selected_coverage_amount {
138            return Err(
139                "bound coverage coverage_amount must match the placement selected_coverage_amount"
140                    .to_string(),
141            );
142        }
143        if self.premium_amount != self.placement.body.selected_premium_amount {
144            return Err(
145                "bound coverage premium_amount must match the placement selected_premium_amount"
146                    .to_string(),
147            );
148        }
149        if !quote_request.provider_policy.bound_coverage_supported {
150            return Err(
151                "bound coverage cannot be issued because the provider policy does not support bound coverage"
152                    .to_string(),
153            );
154        }
155        if !quote_request.provider_policy.claims_supported {
156            return Err(
157                "bound coverage cannot be issued because the provider policy does not support claims"
158                    .to_string(),
159            );
160        }
161        Ok(())
162    }
163}
164
165pub type SignedLiabilityBoundCoverage = SignedExportEnvelope<LiabilityBoundCoverageArtifact>;
166
167#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "snake_case")]
169pub enum LiabilityAutoBindDisposition {
170    AutoBound,
171    ManualReview,
172    Denied,
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "snake_case")]
177pub enum LiabilityAutoBindReasonCode {
178    AuthorityExpired,
179    QuoteExpired,
180    AutoBindDisabled,
181    CoverageExceedsAuthority,
182    PremiumExceedsAuthority,
183    CapitalUnavailable,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(rename_all = "camelCase")]
188pub struct LiabilityAutoBindFinding {
189    pub code: LiabilityAutoBindReasonCode,
190    pub description: String,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
194#[serde(rename_all = "camelCase")]
195pub struct LiabilityAutoBindDecisionArtifact {
196    pub schema: String,
197    pub decision_id: String,
198    pub issued_at: u64,
199    pub authority: SignedLiabilityPricingAuthority,
200    pub quote_response: SignedLiabilityQuoteResponse,
201    pub disposition: LiabilityAutoBindDisposition,
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub findings: Vec<LiabilityAutoBindFinding>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub placement: Option<SignedLiabilityPlacement>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub bound_coverage: Option<SignedLiabilityBoundCoverage>,
208}
209
210impl LiabilityAutoBindDecisionArtifact {
211    pub fn validate(&self) -> Result<(), String> {
212        if !self.authority.verify_signature().map_err(|error| {
213            format!("auto-bind authority signature verification failed: {error}")
214        })? {
215            return Err("auto-bind authority signature verification failed".to_string());
216        }
217        if !self.quote_response.verify_signature().map_err(|error| {
218            format!("auto-bind quote_response signature verification failed: {error}")
219        })? {
220            return Err("auto-bind quote_response signature verification failed".to_string());
221        }
222        self.authority.body.validate()?;
223        self.quote_response.body.validate()?;
224        if self.authority.body.quote_request.body.quote_request_id
225            != self.quote_response.body.quote_request.body.quote_request_id
226        {
227            return Err(
228                "auto-bind authority quote_request_id must match the quote response quote_request_id"
229                    .to_string(),
230            );
231        }
232        if self.authority.body.provider_policy
233            != self.quote_response.body.quote_request.body.provider_policy
234        {
235            return Err(
236                "auto-bind authority provider_policy must match the quote response provider_policy"
237                    .to_string(),
238            );
239        }
240        match self.disposition {
241            LiabilityAutoBindDisposition::AutoBound => {
242                let placement = self
243                    .placement
244                    .as_ref()
245                    .ok_or_else(|| "auto-bound decisions require placement".to_string())?;
246                let bound_coverage = self
247                    .bound_coverage
248                    .as_ref()
249                    .ok_or_else(|| "auto-bound decisions require bound_coverage".to_string())?;
250                if !placement.verify_signature().map_err(|error| {
251                    format!("auto-bind placement signature verification failed: {error}")
252                })? {
253                    return Err("auto-bind placement signature verification failed".to_string());
254                }
255                if !bound_coverage.verify_signature().map_err(|error| {
256                    format!("auto-bind bound coverage signature verification failed: {error}")
257                })? {
258                    return Err(
259                        "auto-bind bound coverage signature verification failed".to_string()
260                    );
261                }
262                placement.body.validate()?;
263                bound_coverage.body.validate()?;
264                if placement.body.quote_response.body != self.quote_response.body {
265                    return Err(
266                        "auto-bind placement quote_response must match the decision quote_response"
267                            .to_string(),
268                    );
269                }
270                if bound_coverage.body.placement.body != placement.body {
271                    return Err(
272                        "auto-bind bound coverage placement must match the decision placement"
273                            .to_string(),
274                    );
275                }
276            }
277            LiabilityAutoBindDisposition::ManualReview | LiabilityAutoBindDisposition::Denied => {
278                if self.placement.is_some() || self.bound_coverage.is_some() {
279                    return Err(
280                        "manual-review and denied auto-bind decisions cannot embed issued placement or bound coverage"
281                            .to_string(),
282                    );
283                }
284            }
285        }
286        Ok(())
287    }
288}
289
290pub type SignedLiabilityAutoBindDecision = SignedExportEnvelope<LiabilityAutoBindDecisionArtifact>;