1use serde::{Deserialize, Serialize};
4
5use crate::capability::scope::MonetaryAmount;
6use crate::credit::{
7 CreditFacilityDisposition, CreditFacilityLifecycleState, SignedCapitalBookReport,
8 SignedCreditFacility, SignedCreditProviderRiskPackage,
9};
10use crate::receipt::lineage::SignedExportEnvelope;
11use crate::underwriting::{
12 SignedUnderwritingDecision, UnderwritingBudgetAction, UnderwritingDecisionLifecycleState,
13 UnderwritingReviewState,
14};
15
16use crate::{
17 validate_currency_code, validate_positive_money, LiabilityCoverageClass,
18 LiabilityEvidenceRequirement,
19};
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum LiabilityQuoteDisposition {
24 Quoted,
25 Declined,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct LiabilityProviderPolicyReference {
31 pub provider_id: String,
32 pub provider_record_id: String,
33 pub display_name: String,
34 pub jurisdiction: String,
35 pub coverage_class: LiabilityCoverageClass,
36 pub currency: String,
37 pub required_evidence: Vec<LiabilityEvidenceRequirement>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub max_coverage_amount: Option<MonetaryAmount>,
40 pub claims_supported: bool,
41 pub quote_ttl_seconds: u64,
42 pub bound_coverage_supported: bool,
43}
44
45impl LiabilityProviderPolicyReference {
46 pub fn validate(&self) -> Result<(), String> {
47 if self.provider_id.trim().is_empty() {
48 return Err("provider policy reference requires provider_id".to_string());
49 }
50 if self.provider_record_id.trim().is_empty() {
51 return Err("provider policy reference requires provider_record_id".to_string());
52 }
53 if self.display_name.trim().is_empty() {
54 return Err("provider policy reference requires display_name".to_string());
55 }
56 if self.jurisdiction.trim().is_empty() {
57 return Err("provider policy reference requires jurisdiction".to_string());
58 }
59 validate_currency_code(&self.currency, "provider policy reference currency")?;
60 if self.quote_ttl_seconds == 0 {
61 return Err(
62 "provider policy reference requires quote_ttl_seconds greater than zero"
63 .to_string(),
64 );
65 }
66 if let Some(max_coverage_amount) = self.max_coverage_amount.as_ref() {
67 if max_coverage_amount.units == 0 {
68 return Err(
69 "provider policy reference max_coverage_amount must be greater than zero"
70 .to_string(),
71 );
72 }
73 if max_coverage_amount.currency.trim().to_ascii_uppercase() != self.currency {
74 return Err("provider policy reference max_coverage_amount currency must match policy currency".to_string());
75 }
76 }
77 Ok(())
78 }
79}
80
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum LiabilityPricingAuthorityEnvelopeKind {
84 ProviderDelegate,
85 RegulatedRole,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "camelCase")]
90pub struct LiabilityPricingAuthorityEnvelope {
91 pub kind: LiabilityPricingAuthorityEnvelopeKind,
92 pub delegate_id: String,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub regulated_role: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub authority_chain_ref: Option<String>,
97}
98
99impl LiabilityPricingAuthorityEnvelope {
100 pub fn validate(&self) -> Result<(), String> {
101 if self.delegate_id.trim().is_empty() {
102 return Err("pricing authority envelope requires delegate_id".to_string());
103 }
104 if matches!(
105 self.kind,
106 LiabilityPricingAuthorityEnvelopeKind::RegulatedRole
107 ) && self
108 .regulated_role
109 .as_deref()
110 .is_none_or(|value| value.trim().is_empty())
111 {
112 return Err(
113 "regulated-role pricing authority envelopes require regulated_role".to_string(),
114 );
115 }
116 Ok(())
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "camelCase")]
122pub struct LiabilityQuoteRequestArtifact {
123 pub schema: String,
124 pub quote_request_id: String,
125 pub issued_at: u64,
126 pub provider_policy: LiabilityProviderPolicyReference,
127 pub requested_coverage_amount: MonetaryAmount,
128 pub requested_effective_from: u64,
129 pub requested_effective_until: u64,
130 pub risk_package: SignedCreditProviderRiskPackage,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub notes: Option<String>,
133}
134
135impl LiabilityQuoteRequestArtifact {
136 pub fn validate(&self) -> Result<(), String> {
137 self.provider_policy.validate()?;
138 validate_positive_money(
139 &self.requested_coverage_amount,
140 "quote request requested_coverage_amount",
141 )?;
142 if self
143 .requested_coverage_amount
144 .currency
145 .trim()
146 .to_ascii_uppercase()
147 != self.provider_policy.currency
148 {
149 return Err(
150 "quote request requested_coverage_amount currency must match provider policy currency"
151 .to_string(),
152 );
153 }
154 if self.requested_effective_until <= self.requested_effective_from {
155 return Err("quote request effective window must have end after start".to_string());
156 }
157 if !self.risk_package.verify_signature().map_err(|error| {
158 format!("quote request risk package signature verification failed: {error}")
159 })? {
160 return Err("quote request risk package signature verification failed".to_string());
161 }
162 if self.risk_package.body.subject_key.trim().is_empty() {
163 return Err("quote request risk package subject_key must not be empty".to_string());
164 }
165 if let Some(max_coverage_amount) = self.provider_policy.max_coverage_amount.as_ref() {
166 if self.requested_coverage_amount.units > max_coverage_amount.units {
167 return Err(
168 "quote request requested_coverage_amount exceeds provider max_coverage_amount"
169 .to_string(),
170 );
171 }
172 }
173 Ok(())
174 }
175}
176
177pub type SignedLiabilityQuoteRequest = SignedExportEnvelope<LiabilityQuoteRequestArtifact>;
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[serde(rename_all = "camelCase")]
181pub struct LiabilityQuoteTerms {
182 pub quoted_coverage_amount: MonetaryAmount,
183 pub quoted_premium_amount: MonetaryAmount,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub quoted_deductible_amount: Option<MonetaryAmount>,
186 pub expires_at: u64,
187}
188
189impl LiabilityQuoteTerms {
190 fn validate_for_request(
191 &self,
192 request: &LiabilityQuoteRequestArtifact,
193 issued_at: u64,
194 ) -> Result<(), String> {
195 validate_positive_money(
196 &self.quoted_coverage_amount,
197 "quote response quoted_coverage_amount",
198 )?;
199 validate_positive_money(
200 &self.quoted_premium_amount,
201 "quote response quoted_premium_amount",
202 )?;
203 if let Some(quoted_deductible_amount) = self.quoted_deductible_amount.as_ref() {
204 validate_positive_money(
205 quoted_deductible_amount,
206 "quote response quoted_deductible_amount",
207 )?;
208 if quoted_deductible_amount
209 .currency
210 .trim()
211 .to_ascii_uppercase()
212 != request.provider_policy.currency
213 {
214 return Err(
215 "quote response quoted_deductible_amount currency must match provider policy currency"
216 .to_string(),
217 );
218 }
219 }
220 if self
221 .quoted_coverage_amount
222 .currency
223 .trim()
224 .to_ascii_uppercase()
225 != request.provider_policy.currency
226 {
227 return Err(
228 "quote response quoted_coverage_amount currency must match provider policy currency"
229 .to_string(),
230 );
231 }
232 if self
233 .quoted_premium_amount
234 .currency
235 .trim()
236 .to_ascii_uppercase()
237 != request.provider_policy.currency
238 {
239 return Err(
240 "quote response quoted_premium_amount currency must match provider policy currency"
241 .to_string(),
242 );
243 }
244 if self.expires_at <= issued_at {
245 return Err("quote response expires_at must be after issuance".to_string());
246 }
247 if self.expires_at
248 > request
249 .issued_at
250 .saturating_add(request.provider_policy.quote_ttl_seconds)
251 {
252 return Err("quote response expires_at exceeds provider policy quote TTL".to_string());
253 }
254 Ok(())
255 }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259#[serde(rename_all = "camelCase")]
260pub struct LiabilityQuoteResponseArtifact {
261 pub schema: String,
262 pub quote_response_id: String,
263 pub issued_at: u64,
264 pub quote_request: SignedLiabilityQuoteRequest,
265 pub provider_quote_ref: String,
266 pub disposition: LiabilityQuoteDisposition,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub supersedes_quote_response_id: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub quoted_terms: Option<LiabilityQuoteTerms>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub decline_reason: Option<String>,
273}
274
275impl LiabilityQuoteResponseArtifact {
276 pub fn validate(&self) -> Result<(), String> {
277 if self.schema != crate::LIABILITY_QUOTE_RESPONSE_ARTIFACT_SCHEMA {
278 return Err(format!(
279 "unsupported liability quote response schema: {}",
280 self.schema
281 ));
282 }
283 let quote_response_id = self.quote_response_id.trim();
284 if quote_response_id.is_empty() {
285 return Err("quote response requires quote_response_id".to_string());
286 }
287 if quote_response_id != self.quote_response_id {
288 return Err(
289 "quote response quote_response_id must not have leading or trailing whitespace"
290 .to_string(),
291 );
292 }
293 if self.quote_response_id.chars().any(char::is_control) {
294 return Err(
295 "quote response quote_response_id must not include control characters".to_string(),
296 );
297 }
298 if !self.quote_request.verify_signature().map_err(|error| {
299 format!("quote response quote_request signature verification failed: {error}")
300 })? {
301 return Err("quote response quote_request signature verification failed".to_string());
302 }
303 self.quote_request.body.validate()?;
304 if self.provider_quote_ref.trim().is_empty() {
305 return Err("quote response requires provider_quote_ref".to_string());
306 }
307 match self.disposition {
308 LiabilityQuoteDisposition::Quoted => {
309 let quoted_terms = self
310 .quoted_terms
311 .as_ref()
312 .ok_or_else(|| "quoted quote responses require quoted_terms".to_string())?;
313 quoted_terms.validate_for_request(&self.quote_request.body, self.issued_at)?;
314 if self.decline_reason.is_some() {
315 return Err("quoted quote responses cannot include decline_reason".to_string());
316 }
317 }
318 LiabilityQuoteDisposition::Declined => {
319 if self.quoted_terms.is_some() {
320 return Err("declined quote responses cannot include quoted_terms".to_string());
321 }
322 if self
323 .decline_reason
324 .as_deref()
325 .is_none_or(|value| value.trim().is_empty())
326 {
327 return Err("declined quote responses require decline_reason".to_string());
328 }
329 }
330 }
331 Ok(())
332 }
333}
334
335pub type SignedLiabilityQuoteResponse = SignedExportEnvelope<LiabilityQuoteResponseArtifact>;
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
338#[serde(rename_all = "camelCase")]
339pub struct LiabilityPricingAuthorityArtifact {
340 pub schema: String,
341 pub authority_id: String,
342 pub issued_at: u64,
343 pub quote_request: SignedLiabilityQuoteRequest,
344 pub provider_policy: LiabilityProviderPolicyReference,
345 pub facility: SignedCreditFacility,
346 pub underwriting_decision: SignedUnderwritingDecision,
347 pub capital_book: SignedCapitalBookReport,
348 pub envelope: LiabilityPricingAuthorityEnvelope,
349 pub max_coverage_amount: MonetaryAmount,
350 pub max_premium_amount: MonetaryAmount,
351 pub expires_at: u64,
352 pub auto_bind_enabled: bool,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub notes: Option<String>,
355}
356
357impl LiabilityPricingAuthorityArtifact {
358 pub fn validate(&self) -> Result<(), String> {
359 if !self.quote_request.verify_signature().map_err(|error| {
360 format!("pricing authority quote_request signature verification failed: {error}")
361 })? {
362 return Err(
363 "pricing authority quote_request signature verification failed".to_string(),
364 );
365 }
366 if !self.facility.verify_signature().map_err(|error| {
367 format!("pricing authority facility signature verification failed: {error}")
368 })? {
369 return Err("pricing authority facility signature verification failed".to_string());
370 }
371 if !self
372 .underwriting_decision
373 .verify_signature()
374 .map_err(|error| {
375 format!(
376 "pricing authority underwriting decision signature verification failed: {error}"
377 )
378 })?
379 {
380 return Err(
381 "pricing authority underwriting decision signature verification failed".to_string(),
382 );
383 }
384 if !self.capital_book.verify_signature().map_err(|error| {
385 format!("pricing authority capital book signature verification failed: {error}")
386 })? {
387 return Err("pricing authority capital book signature verification failed".to_string());
388 }
389 self.quote_request.body.validate()?;
390 self.provider_policy.validate()?;
391 self.envelope.validate()?;
392 if self.provider_policy != self.quote_request.body.provider_policy {
393 return Err(
394 "pricing authority provider_policy must match the quote request provider_policy"
395 .to_string(),
396 );
397 }
398 validate_positive_money(
399 &self.max_coverage_amount,
400 "pricing authority max_coverage_amount",
401 )?;
402 validate_positive_money(
403 &self.max_premium_amount,
404 "pricing authority max_premium_amount",
405 )?;
406 if self
407 .max_coverage_amount
408 .currency
409 .trim()
410 .to_ascii_uppercase()
411 != self.provider_policy.currency
412 {
413 return Err(
414 "pricing authority max_coverage_amount currency must match provider policy currency"
415 .to_string(),
416 );
417 }
418 if self.max_premium_amount.currency.trim().to_ascii_uppercase()
419 != self.provider_policy.currency
420 {
421 return Err(
422 "pricing authority max_premium_amount currency must match provider policy currency"
423 .to_string(),
424 );
425 }
426 if self.expires_at <= self.issued_at {
427 return Err("pricing authority expires_at must be after issuance".to_string());
428 }
429 if self.expires_at
430 > self
431 .quote_request
432 .body
433 .issued_at
434 .saturating_add(self.provider_policy.quote_ttl_seconds)
435 {
436 return Err(
437 "pricing authority expires_at exceeds provider policy quote TTL".to_string(),
438 );
439 }
440 if self.facility.body.lifecycle_state != CreditFacilityLifecycleState::Active {
441 return Err("pricing authority requires an active facility".to_string());
442 }
443 if self.facility.body.report.disposition != CreditFacilityDisposition::Grant {
444 return Err("pricing authority requires a granted facility".to_string());
445 }
446 let facility_terms = self
447 .facility
448 .body
449 .report
450 .terms
451 .as_ref()
452 .ok_or_else(|| "pricing authority requires facility terms".to_string())?;
453 if facility_terms
454 .credit_limit
455 .currency
456 .trim()
457 .to_ascii_uppercase()
458 != self.provider_policy.currency
459 {
460 return Err(
461 "pricing authority facility credit limit currency must match provider policy currency"
462 .to_string(),
463 );
464 }
465 if self.max_coverage_amount.units > facility_terms.credit_limit.units {
466 return Err(
467 "pricing authority max_coverage_amount exceeds facility credit limit".to_string(),
468 );
469 }
470 if let Some(max_coverage_amount) = self.provider_policy.max_coverage_amount.as_ref() {
471 if self.max_coverage_amount.units > max_coverage_amount.units {
472 return Err(
473 "pricing authority max_coverage_amount exceeds provider max_coverage_amount"
474 .to_string(),
475 );
476 }
477 }
478 if self.underwriting_decision.body.lifecycle_state
479 != UnderwritingDecisionLifecycleState::Active
480 {
481 return Err("pricing authority requires an active underwriting decision".to_string());
482 }
483 if self.underwriting_decision.body.review_state != UnderwritingReviewState::Approved {
484 return Err("pricing authority requires an approved underwriting decision".to_string());
485 }
486 if matches!(
487 self.underwriting_decision.body.budget.action,
488 UnderwritingBudgetAction::Hold | UnderwritingBudgetAction::Deny
489 ) {
490 return Err(
491 "pricing authority requires underwriting budget action preserve or reduce"
492 .to_string(),
493 );
494 }
495 if let Some(quoted_amount) = self
496 .underwriting_decision
497 .body
498 .premium
499 .quoted_amount
500 .as_ref()
501 {
502 if quoted_amount.currency.trim().to_ascii_uppercase() != self.provider_policy.currency {
503 return Err(
504 "pricing authority underwriting premium currency must match provider policy currency"
505 .to_string(),
506 );
507 }
508 if self.max_premium_amount.units > quoted_amount.units {
509 return Err(
510 "pricing authority max_premium_amount exceeds underwriting quoted premium"
511 .to_string(),
512 );
513 }
514 }
515 let subject_key = self
516 .quote_request
517 .body
518 .risk_package
519 .body
520 .subject_key
521 .as_str();
522 if self.capital_book.body.subject_key != subject_key {
523 return Err(
524 "pricing authority capital book subject must match the quote request subject"
525 .to_string(),
526 );
527 }
528 if self.capital_book.body.summary.mixed_currency_book {
529 return Err(
530 "pricing authority cannot be issued against a mixed-currency capital book"
531 .to_string(),
532 );
533 }
534 let facility_source = self
535 .capital_book
536 .body
537 .sources
538 .iter()
539 .find(|source| {
540 source.facility_id.as_deref() == Some(self.facility.body.facility_id.as_str())
541 })
542 .ok_or_else(|| {
543 "pricing authority capital book must include the referenced facility source"
544 .to_string()
545 })?;
546 if facility_source.currency.trim().to_ascii_uppercase() != self.provider_policy.currency {
547 return Err(
548 "pricing authority capital book source currency must match provider policy currency"
549 .to_string(),
550 );
551 }
552 if let Some(committed_amount) = facility_source.committed_amount.as_ref() {
553 let available_units = committed_amount
554 .units
555 .saturating_sub(
556 facility_source
557 .disbursed_amount
558 .as_ref()
559 .map_or(0, |amount| amount.units),
560 )
561 .saturating_sub(
562 facility_source
563 .impaired_amount
564 .as_ref()
565 .map_or(0, |amount| amount.units),
566 );
567 if self.max_coverage_amount.units > available_units {
568 return Err(
569 "pricing authority max_coverage_amount exceeds capital book available committed amount"
570 .to_string(),
571 );
572 }
573 }
574 if self.auto_bind_enabled
575 && (!self.provider_policy.bound_coverage_supported
576 || !self.provider_policy.claims_supported)
577 {
578 return Err(
579 "pricing authority cannot enable auto_bind because the provider policy does not support bound coverage and claims"
580 .to_string(),
581 );
582 }
583 Ok(())
584 }
585}
586
587pub type SignedLiabilityPricingAuthority = SignedExportEnvelope<LiabilityPricingAuthorityArtifact>;