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}