Skip to main content

chio_credit/factor/
request.rs

1use super::*;
2use crate::obligation::ObligationAtomV1;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct NormalizedAssignmentRequestInputV1 {
6    pub obligation_id: String,
7    pub obligation_atom_digest: String,
8    pub claim_digest: String,
9    pub offer_digest: String,
10    pub seller_id: String,
11    pub buyer_id: String,
12    pub buyer_settlement_destination_ref: String,
13    pub agreed_price: MonetaryAmount,
14    pub agreed_discount_bps: u16,
15    pub expected_disposition_version: u64,
16    pub expected_disposition_lifecycle_fence: u64,
17    pub expected_settlement_lifecycle_version: u64,
18    pub expected_settlement_lifecycle_fence: u64,
19    pub action_nonce: String,
20    pub effective_at_unix_ms: u64,
21    pub due_at_unix_ms: u64,
22    pub expires_at_unix_ms: u64,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct NormalizedAssignmentRequestV1 {
28    schema: String,
29    obligation_id: String,
30    obligation_atom_digest: String,
31    claim_digest: String,
32    offer_digest: String,
33    seller_id: String,
34    buyer_id: String,
35    buyer_settlement_destination_ref: String,
36    agreed_price: MonetaryAmount,
37    agreed_discount_bps: u16,
38    expected_disposition_version: u64,
39    expected_disposition_lifecycle_fence: u64,
40    expected_settlement_lifecycle_version: u64,
41    expected_settlement_lifecycle_fence: u64,
42    action_nonce: String,
43    effective_at_unix_ms: u64,
44    due_at_unix_ms: u64,
45    expires_at_unix_ms: u64,
46}
47
48impl NormalizedAssignmentRequestV1 {
49    pub fn new(input: NormalizedAssignmentRequestInputV1) -> Result<Self, FactorError> {
50        let request = Self {
51            schema: FACTOR_NORMALIZED_ASSIGNMENT_REQUEST_SCHEMA.to_owned(),
52            obligation_id: input.obligation_id,
53            obligation_atom_digest: input.obligation_atom_digest,
54            claim_digest: input.claim_digest,
55            offer_digest: input.offer_digest,
56            seller_id: input.seller_id,
57            buyer_id: input.buyer_id,
58            buyer_settlement_destination_ref: input.buyer_settlement_destination_ref,
59            agreed_price: input.agreed_price,
60            agreed_discount_bps: input.agreed_discount_bps,
61            expected_disposition_version: input.expected_disposition_version,
62            expected_disposition_lifecycle_fence: input.expected_disposition_lifecycle_fence,
63            expected_settlement_lifecycle_version: input.expected_settlement_lifecycle_version,
64            expected_settlement_lifecycle_fence: input.expected_settlement_lifecycle_fence,
65            action_nonce: input.action_nonce,
66            effective_at_unix_ms: input.effective_at_unix_ms,
67            due_at_unix_ms: input.due_at_unix_ms,
68            expires_at_unix_ms: input.expires_at_unix_ms,
69        };
70        request.validate()?;
71        Ok(request)
72    }
73
74    pub fn validate(&self) -> Result<(), FactorError> {
75        if self.schema != FACTOR_NORMALIZED_ASSIGNMENT_REQUEST_SCHEMA {
76            return Err(FactorError::InvalidField("normalized_request_schema"));
77        }
78        for (field, value) in [
79            ("obligation_id", &self.obligation_id),
80            ("obligation_atom_digest", &self.obligation_atom_digest),
81            ("claim_digest", &self.claim_digest),
82            ("offer_digest", &self.offer_digest),
83        ] {
84            validate_digest(field, value)?;
85        }
86        for (field, value) in [
87            ("seller_id", &self.seller_id),
88            ("buyer_id", &self.buyer_id),
89            (
90                "buyer_settlement_destination_ref",
91                &self.buyer_settlement_destination_ref,
92            ),
93            ("action_nonce", &self.action_nonce),
94        ] {
95            validate_text(field, value)?;
96        }
97        validate_amount("agreed_price", &self.agreed_price, true)?;
98        validate_basis_points(self.agreed_discount_bps)?;
99        for (field, value) in [
100            (
101                "expected_disposition_version",
102                self.expected_disposition_version,
103            ),
104            (
105                "expected_disposition_lifecycle_fence",
106                self.expected_disposition_lifecycle_fence,
107            ),
108            (
109                "expected_settlement_lifecycle_version",
110                self.expected_settlement_lifecycle_version,
111            ),
112            (
113                "expected_settlement_lifecycle_fence",
114                self.expected_settlement_lifecycle_fence,
115            ),
116            ("effective_at_unix_ms", self.effective_at_unix_ms),
117            ("due_at_unix_ms", self.due_at_unix_ms),
118            ("expires_at_unix_ms", self.expires_at_unix_ms),
119        ] {
120            validate_positive(field, value)?;
121        }
122        if self.seller_id == self.buyer_id
123            || self.expected_disposition_version != self.expected_disposition_lifecycle_fence
124            || self.expected_settlement_lifecycle_version
125                != self.expected_settlement_lifecycle_fence
126            || self.effective_at_unix_ms >= self.expires_at_unix_ms
127            || self.expires_at_unix_ms > self.due_at_unix_ms
128        {
129            return Err(FactorError::InvalidField("normalized_request_terms"));
130        }
131        Ok(())
132    }
133
134    pub fn canonical_bytes(&self) -> Result<Vec<u8>, FactorError> {
135        self.validate()?;
136        canonical_bytes(self)
137    }
138
139    pub fn digest(&self) -> Result<String, FactorError> {
140        self.validate()?;
141        domain_digest(NORMALIZED_REQUEST_DIGEST_DOMAIN, self)
142    }
143
144    #[must_use]
145    pub fn obligation_id(&self) -> &str {
146        &self.obligation_id
147    }
148
149    #[must_use]
150    pub fn obligation_atom_digest(&self) -> &str {
151        &self.obligation_atom_digest
152    }
153
154    #[must_use]
155    pub fn claim_digest(&self) -> &str {
156        &self.claim_digest
157    }
158
159    #[must_use]
160    pub fn offer_digest(&self) -> &str {
161        &self.offer_digest
162    }
163
164    #[must_use]
165    pub fn seller_id(&self) -> &str {
166        &self.seller_id
167    }
168
169    #[must_use]
170    pub fn buyer_id(&self) -> &str {
171        &self.buyer_id
172    }
173
174    #[must_use]
175    pub fn buyer_settlement_destination_ref(&self) -> &str {
176        &self.buyer_settlement_destination_ref
177    }
178
179    #[must_use]
180    pub const fn agreed_price(&self) -> &MonetaryAmount {
181        &self.agreed_price
182    }
183
184    #[must_use]
185    pub const fn agreed_discount_bps(&self) -> u16 {
186        self.agreed_discount_bps
187    }
188
189    #[must_use]
190    pub const fn expected_disposition_version(&self) -> u64 {
191        self.expected_disposition_version
192    }
193
194    #[must_use]
195    pub const fn expected_disposition_lifecycle_fence(&self) -> u64 {
196        self.expected_disposition_lifecycle_fence
197    }
198
199    #[must_use]
200    pub const fn expected_settlement_lifecycle_version(&self) -> u64 {
201        self.expected_settlement_lifecycle_version
202    }
203
204    #[must_use]
205    pub const fn expected_settlement_lifecycle_fence(&self) -> u64 {
206        self.expected_settlement_lifecycle_fence
207    }
208
209    #[must_use]
210    pub fn action_nonce(&self) -> &str {
211        &self.action_nonce
212    }
213
214    #[must_use]
215    pub const fn effective_at_unix_ms(&self) -> u64 {
216        self.effective_at_unix_ms
217    }
218
219    #[must_use]
220    pub const fn due_at_unix_ms(&self) -> u64 {
221        self.due_at_unix_ms
222    }
223
224    #[must_use]
225    pub const fn expires_at_unix_ms(&self) -> u64 {
226        self.expires_at_unix_ms
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct ReceivableClaimInputV1 {
232    pub obligation_id: String,
233    pub obligation_atom_digest: String,
234    pub seller_id: String,
235    pub receipt_id: String,
236    pub receipt_digest: String,
237    pub iou_id: String,
238    pub iou_digest: String,
239    pub payee_binding_digest: String,
240    pub status_proof_digest: String,
241    pub face_value: MonetaryAmount,
242    pub due_at_unix_ms: u64,
243    pub built_at_unix_ms: u64,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase", deny_unknown_fields)]
248pub struct ReceivableClaimV1 {
249    schema: String,
250    claim_id: String,
251    obligation_id: String,
252    obligation_atom_digest: String,
253    seller_id: String,
254    receipt_id: String,
255    receipt_digest: String,
256    iou_id: String,
257    iou_digest: String,
258    payee_binding_digest: String,
259    status_proof_digest: String,
260    face_value: MonetaryAmount,
261    due_at_unix_ms: u64,
262    built_at_unix_ms: u64,
263}
264
265#[derive(Serialize)]
266#[serde(rename_all = "camelCase")]
267struct ReceivableClaimIdPreimage<'a> {
268    schema: &'a str,
269    obligation_id: &'a str,
270    obligation_atom_digest: &'a str,
271    seller_id: &'a str,
272    receipt_id: &'a str,
273    receipt_digest: &'a str,
274    iou_id: &'a str,
275    iou_digest: &'a str,
276    payee_binding_digest: &'a str,
277    status_proof_digest: &'a str,
278    face_value: &'a MonetaryAmount,
279    due_at_unix_ms: u64,
280    built_at_unix_ms: u64,
281}
282
283impl ReceivableClaimV1 {
284    pub fn new(input: ReceivableClaimInputV1) -> Result<Self, FactorError> {
285        let mut claim = Self {
286            schema: FACTOR_RECEIVABLE_CLAIM_SCHEMA.to_owned(),
287            claim_id: String::new(),
288            obligation_id: input.obligation_id,
289            obligation_atom_digest: input.obligation_atom_digest,
290            seller_id: input.seller_id,
291            receipt_id: input.receipt_id,
292            receipt_digest: input.receipt_digest,
293            iou_id: input.iou_id,
294            iou_digest: input.iou_digest,
295            payee_binding_digest: input.payee_binding_digest,
296            status_proof_digest: input.status_proof_digest,
297            face_value: input.face_value,
298            due_at_unix_ms: input.due_at_unix_ms,
299            built_at_unix_ms: input.built_at_unix_ms,
300        };
301        claim.claim_id = claim.derived_claim_id()?;
302        claim.validate()?;
303        Ok(claim)
304    }
305
306    pub fn validate(&self) -> Result<(), FactorError> {
307        if self.schema != FACTOR_RECEIVABLE_CLAIM_SCHEMA {
308            return Err(FactorError::InvalidField("claim_schema"));
309        }
310        for (field, value) in [
311            ("claim_id", &self.claim_id),
312            ("obligation_id", &self.obligation_id),
313            ("obligation_atom_digest", &self.obligation_atom_digest),
314            ("receipt_digest", &self.receipt_digest),
315            ("iou_digest", &self.iou_digest),
316            ("payee_binding_digest", &self.payee_binding_digest),
317            ("status_proof_digest", &self.status_proof_digest),
318        ] {
319            validate_digest(field, value)?;
320        }
321        for (field, value) in [
322            ("seller_id", &self.seller_id),
323            ("receipt_id", &self.receipt_id),
324            ("iou_id", &self.iou_id),
325        ] {
326            validate_text(field, value)?;
327        }
328        validate_amount("face_value", &self.face_value, false)?;
329        validate_positive("due_at_unix_ms", self.due_at_unix_ms)?;
330        validate_positive("built_at_unix_ms", self.built_at_unix_ms)?;
331        if self.built_at_unix_ms >= self.due_at_unix_ms
332            || self.claim_id != self.derived_claim_id()?
333        {
334            return Err(FactorError::InvalidField("claim_terms"));
335        }
336        Ok(())
337    }
338
339    fn derived_claim_id(&self) -> Result<String, FactorError> {
340        domain_digest(
341            CLAIM_ID_DOMAIN,
342            &ReceivableClaimIdPreimage {
343                schema: &self.schema,
344                obligation_id: &self.obligation_id,
345                obligation_atom_digest: &self.obligation_atom_digest,
346                seller_id: &self.seller_id,
347                receipt_id: &self.receipt_id,
348                receipt_digest: &self.receipt_digest,
349                iou_id: &self.iou_id,
350                iou_digest: &self.iou_digest,
351                payee_binding_digest: &self.payee_binding_digest,
352                status_proof_digest: &self.status_proof_digest,
353                face_value: &self.face_value,
354                due_at_unix_ms: self.due_at_unix_ms,
355                built_at_unix_ms: self.built_at_unix_ms,
356            },
357        )
358    }
359
360    pub fn canonical_bytes(&self) -> Result<Vec<u8>, FactorError> {
361        self.validate()?;
362        canonical_bytes(self)
363    }
364
365    pub fn digest(&self) -> Result<String, FactorError> {
366        self.validate()?;
367        domain_digest(CLAIM_DIGEST_DOMAIN, self)
368    }
369
370    pub fn validate_against_atom(&self, atom: &ObligationAtomV1) -> Result<(), FactorError> {
371        self.validate()?;
372        atom.validate().map_err(|_| FactorError::BindingMismatch)?;
373        if self.obligation_id != atom.obligation_id()
374            || self.obligation_atom_digest
375                != atom.digest().map_err(|_| FactorError::BindingMismatch)?
376            || self.receipt_id != atom.source_receipt_id()
377            || self.receipt_digest != atom.source_receipt_digest()
378            || self.seller_id != atom.original_creditor_id()
379            || self.payee_binding_digest != atom.payee_binding_digest()
380            || self.face_value != *atom.amount()
381            || self.due_at_unix_ms != atom.due_at_unix_ms()
382            || self.built_at_unix_ms < atom.created_at_unix_ms()
383        {
384            return Err(FactorError::BindingMismatch);
385        }
386        Ok(())
387    }
388
389    #[must_use]
390    pub fn claim_id(&self) -> &str {
391        &self.claim_id
392    }
393
394    #[must_use]
395    pub fn obligation_id(&self) -> &str {
396        &self.obligation_id
397    }
398
399    #[must_use]
400    pub fn obligation_atom_digest(&self) -> &str {
401        &self.obligation_atom_digest
402    }
403
404    #[must_use]
405    pub fn seller_id(&self) -> &str {
406        &self.seller_id
407    }
408
409    #[must_use]
410    pub fn receipt_id(&self) -> &str {
411        &self.receipt_id
412    }
413
414    #[must_use]
415    pub fn receipt_digest(&self) -> &str {
416        &self.receipt_digest
417    }
418
419    #[must_use]
420    pub fn iou_id(&self) -> &str {
421        &self.iou_id
422    }
423
424    #[must_use]
425    pub fn iou_digest(&self) -> &str {
426        &self.iou_digest
427    }
428
429    #[must_use]
430    pub fn payee_binding_digest(&self) -> &str {
431        &self.payee_binding_digest
432    }
433
434    #[must_use]
435    pub fn status_proof_digest(&self) -> &str {
436        &self.status_proof_digest
437    }
438
439    #[must_use]
440    pub const fn face_value(&self) -> &MonetaryAmount {
441        &self.face_value
442    }
443
444    #[must_use]
445    pub const fn due_at_unix_ms(&self) -> u64 {
446        self.due_at_unix_ms
447    }
448
449    #[must_use]
450    pub const fn built_at_unix_ms(&self) -> u64 {
451        self.built_at_unix_ms
452    }
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase", deny_unknown_fields)]
457pub struct AssignmentOfferV1 {
458    schema: String,
459    offer_id: String,
460    claim_id: String,
461    claim_digest: String,
462    seller_id: String,
463    asking_discount_bps: u16,
464    minimum_price: MonetaryAmount,
465    issued_at_unix_ms: u64,
466    expires_at_unix_ms: u64,
467    due_at_unix_ms: u64,
468}
469
470#[derive(Serialize)]
471#[serde(rename_all = "camelCase")]
472struct AssignmentOfferIdPreimage<'a> {
473    schema: &'a str,
474    claim_id: &'a str,
475    claim_digest: &'a str,
476    seller_id: &'a str,
477    asking_discount_bps: u16,
478    minimum_price: &'a MonetaryAmount,
479    issued_at_unix_ms: u64,
480    expires_at_unix_ms: u64,
481    due_at_unix_ms: u64,
482}
483
484impl AssignmentOfferV1 {
485    pub fn new(
486        claim: &ReceivableClaimV1,
487        asking_discount_bps: u16,
488        issued_at_unix_ms: u64,
489        expires_at_unix_ms: u64,
490    ) -> Result<Self, FactorError> {
491        claim.validate()?;
492        let mut offer = Self {
493            schema: FACTOR_ASSIGNMENT_OFFER_SCHEMA.to_owned(),
494            offer_id: String::new(),
495            claim_id: claim.claim_id.clone(),
496            claim_digest: claim.digest()?,
497            seller_id: claim.seller_id.clone(),
498            asking_discount_bps,
499            minimum_price: discounted_amount(claim.face_value(), asking_discount_bps)?,
500            issued_at_unix_ms,
501            expires_at_unix_ms,
502            due_at_unix_ms: claim.due_at_unix_ms,
503        };
504        offer.offer_id = offer.derived_offer_id()?;
505        offer.validate()?;
506        Ok(offer)
507    }
508
509    pub fn validate(&self) -> Result<(), FactorError> {
510        if self.schema != FACTOR_ASSIGNMENT_OFFER_SCHEMA {
511            return Err(FactorError::InvalidField("offer_schema"));
512        }
513        for (field, value) in [
514            ("offer_id", &self.offer_id),
515            ("claim_id", &self.claim_id),
516            ("claim_digest", &self.claim_digest),
517        ] {
518            validate_digest(field, value)?;
519        }
520        validate_text("seller_id", &self.seller_id)?;
521        validate_basis_points(self.asking_discount_bps)?;
522        validate_amount("minimum_price", &self.minimum_price, true)?;
523        for (field, value) in [
524            ("issued_at_unix_ms", self.issued_at_unix_ms),
525            ("expires_at_unix_ms", self.expires_at_unix_ms),
526            ("due_at_unix_ms", self.due_at_unix_ms),
527        ] {
528            validate_positive(field, value)?;
529        }
530        if self.issued_at_unix_ms >= self.expires_at_unix_ms
531            || self.expires_at_unix_ms >= self.due_at_unix_ms
532            || self.offer_id != self.derived_offer_id()?
533        {
534            return Err(FactorError::InvalidField("offer_terms"));
535        }
536        Ok(())
537    }
538
539    fn derived_offer_id(&self) -> Result<String, FactorError> {
540        domain_digest(
541            OFFER_ID_DOMAIN,
542            &AssignmentOfferIdPreimage {
543                schema: &self.schema,
544                claim_id: &self.claim_id,
545                claim_digest: &self.claim_digest,
546                seller_id: &self.seller_id,
547                asking_discount_bps: self.asking_discount_bps,
548                minimum_price: &self.minimum_price,
549                issued_at_unix_ms: self.issued_at_unix_ms,
550                expires_at_unix_ms: self.expires_at_unix_ms,
551                due_at_unix_ms: self.due_at_unix_ms,
552            },
553        )
554    }
555
556    pub fn canonical_bytes(&self) -> Result<Vec<u8>, FactorError> {
557        self.validate()?;
558        canonical_bytes(self)
559    }
560
561    pub fn digest(&self) -> Result<String, FactorError> {
562        self.validate()?;
563        domain_digest(OFFER_DIGEST_DOMAIN, self)
564    }
565
566    #[must_use]
567    pub fn offer_id(&self) -> &str {
568        &self.offer_id
569    }
570
571    #[must_use]
572    pub fn claim_id(&self) -> &str {
573        &self.claim_id
574    }
575
576    #[must_use]
577    pub fn claim_digest(&self) -> &str {
578        &self.claim_digest
579    }
580
581    #[must_use]
582    pub fn seller_id(&self) -> &str {
583        &self.seller_id
584    }
585
586    #[must_use]
587    pub const fn asking_discount_bps(&self) -> u16 {
588        self.asking_discount_bps
589    }
590
591    #[must_use]
592    pub const fn minimum_price(&self) -> &MonetaryAmount {
593        &self.minimum_price
594    }
595
596    #[must_use]
597    pub const fn issued_at_unix_ms(&self) -> u64 {
598        self.issued_at_unix_ms
599    }
600
601    #[must_use]
602    pub const fn expires_at_unix_ms(&self) -> u64 {
603        self.expires_at_unix_ms
604    }
605
606    #[must_use]
607    pub const fn due_at_unix_ms(&self) -> u64 {
608        self.due_at_unix_ms
609    }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
614pub enum DiscountQuoteOutcomeV1 {
615    Quoted {
616        resolved_discount_bps: u16,
617        quoted_price: MonetaryAmount,
618    },
619    Refused {
620        refusal_reason: String,
621    },
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625#[serde(rename_all = "camelCase", deny_unknown_fields)]
626pub struct DiscountQuoteV1 {
627    schema: String,
628    quote_id: String,
629    claim_id: String,
630    claim_digest: String,
631    underwriting_decision_digest: String,
632    scorecard_digest: String,
633    outcome: DiscountQuoteOutcomeV1,
634}
635
636#[derive(Serialize)]
637#[serde(rename_all = "camelCase")]
638struct DiscountQuoteIdPreimage<'a> {
639    schema: &'a str,
640    claim_id: &'a str,
641    claim_digest: &'a str,
642    underwriting_decision_digest: &'a str,
643    scorecard_digest: &'a str,
644    outcome: &'a DiscountQuoteOutcomeV1,
645}
646
647impl DiscountQuoteV1 {
648    pub fn quoted(
649        claim: &ReceivableClaimV1,
650        underwriting_decision_digest: String,
651        scorecard_digest: String,
652        resolved_discount_bps: u16,
653    ) -> Result<Self, FactorError> {
654        let quoted_price = discounted_amount(claim.face_value(), resolved_discount_bps)?;
655        Self::new(
656            claim,
657            underwriting_decision_digest,
658            scorecard_digest,
659            DiscountQuoteOutcomeV1::Quoted {
660                resolved_discount_bps,
661                quoted_price,
662            },
663        )
664    }
665
666    pub fn refused(
667        claim: &ReceivableClaimV1,
668        underwriting_decision_digest: String,
669        scorecard_digest: String,
670        refusal_reason: String,
671    ) -> Result<Self, FactorError> {
672        Self::new(
673            claim,
674            underwriting_decision_digest,
675            scorecard_digest,
676            DiscountQuoteOutcomeV1::Refused { refusal_reason },
677        )
678    }
679
680    fn new(
681        claim: &ReceivableClaimV1,
682        underwriting_decision_digest: String,
683        scorecard_digest: String,
684        outcome: DiscountQuoteOutcomeV1,
685    ) -> Result<Self, FactorError> {
686        claim.validate()?;
687        let mut quote = Self {
688            schema: FACTOR_DISCOUNT_QUOTE_SCHEMA.to_owned(),
689            quote_id: String::new(),
690            claim_id: claim.claim_id.clone(),
691            claim_digest: claim.digest()?,
692            underwriting_decision_digest,
693            scorecard_digest,
694            outcome,
695        };
696        quote.quote_id = quote.derived_quote_id()?;
697        quote.validate_against_claim(claim)?;
698        Ok(quote)
699    }
700
701    pub fn validate(&self) -> Result<(), FactorError> {
702        if self.schema != FACTOR_DISCOUNT_QUOTE_SCHEMA {
703            return Err(FactorError::InvalidField("discount_quote_schema"));
704        }
705        for (field, value) in [
706            ("quote_id", &self.quote_id),
707            ("claim_id", &self.claim_id),
708            ("claim_digest", &self.claim_digest),
709            (
710                "underwriting_decision_digest",
711                &self.underwriting_decision_digest,
712            ),
713            ("scorecard_digest", &self.scorecard_digest),
714        ] {
715            validate_digest(field, value)?;
716        }
717        match &self.outcome {
718            DiscountQuoteOutcomeV1::Quoted {
719                resolved_discount_bps,
720                quoted_price,
721            } => {
722                validate_basis_points(*resolved_discount_bps)?;
723                validate_amount("quoted_price", quoted_price, true)?;
724            }
725            DiscountQuoteOutcomeV1::Refused { refusal_reason } => {
726                validate_text("refusal_reason", refusal_reason)?;
727            }
728        }
729        if self.quote_id != self.derived_quote_id()? {
730            return Err(FactorError::InvalidField("quote_id"));
731        }
732        Ok(())
733    }
734
735    pub fn validate_against_claim(&self, claim: &ReceivableClaimV1) -> Result<(), FactorError> {
736        self.validate()?;
737        claim.validate()?;
738        if self.claim_id != claim.claim_id() || self.claim_digest != claim.digest()? {
739            return Err(FactorError::BindingMismatch);
740        }
741        if let DiscountQuoteOutcomeV1::Quoted {
742            resolved_discount_bps,
743            quoted_price,
744        } = &self.outcome
745        {
746            let expected = discounted_amount(claim.face_value(), *resolved_discount_bps)?;
747            if quoted_price != &expected {
748                return Err(FactorError::BindingMismatch);
749            }
750        }
751        Ok(())
752    }
753
754    fn derived_quote_id(&self) -> Result<String, FactorError> {
755        domain_digest(
756            QUOTE_ID_DOMAIN,
757            &DiscountQuoteIdPreimage {
758                schema: &self.schema,
759                claim_id: &self.claim_id,
760                claim_digest: &self.claim_digest,
761                underwriting_decision_digest: &self.underwriting_decision_digest,
762                scorecard_digest: &self.scorecard_digest,
763                outcome: &self.outcome,
764            },
765        )
766    }
767
768    pub fn canonical_bytes(&self) -> Result<Vec<u8>, FactorError> {
769        self.validate()?;
770        canonical_bytes(self)
771    }
772
773    pub fn digest(&self) -> Result<String, FactorError> {
774        self.validate()?;
775        domain_digest(QUOTE_DIGEST_DOMAIN, self)
776    }
777
778    #[must_use]
779    pub fn quote_id(&self) -> &str {
780        &self.quote_id
781    }
782
783    #[must_use]
784    pub const fn outcome(&self) -> &DiscountQuoteOutcomeV1 {
785        &self.outcome
786    }
787}