1use 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>;