Skip to main content

chio_market/
quote.rs

1//! Liability quote request/response and pricing-authority artifacts.
2
3use 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>;