1use std::{collections::HashSet, fmt, hash::Hash};
2
3use super::amounts::compare_decimals;
4
5use crate::{
6 validation::{
7 parse_utc_timestamp, validate_asset_text, validate_decimal_text, validate_uuid_v4,
8 },
9 EventId, PaykitError, PaymentEndpointIdentifier, PrivateMessageKind, Result,
10};
11
12const ALLOWANCE_V1_VERSION: u8 = 1;
14
15macro_rules! redacted_debug {
19 ($($ty:ident),* $(,)?) => {$(
20 impl fmt::Debug for $ty {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 f.write_str(concat!(stringify!($ty), "(<redacted>)"))
23 }
24 }
25 )*};
26}
27
28redacted_debug!(
29 AllowanceAmountRange,
30 AllowancePeriod,
31 AllowancePeriodLimit,
32 AllowanceTerms,
33 AllowanceTermsBuilder,
34);
35
36#[derive(Clone, Debug, PartialEq, Eq, Hash)]
38pub struct AllowanceId(String);
39
40impl AllowanceId {
41 pub fn new(id: impl Into<String>) -> Result<Self> {
43 validate_uuid_v4(id.into(), "Allowance ID").map(Self)
44 }
45
46 pub fn new_v4() -> Self {
48 Self(uuid::Uuid::new_v4().to_string())
49 }
50
51 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57impl fmt::Display for AllowanceId {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63impl AsRef<str> for AllowanceId {
64 fn as_ref(&self) -> &str {
65 &self.0
66 }
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum AllowanceRole {
72 Allower,
74 Allowee,
76}
77
78impl AllowanceRole {
79 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::Allower => "allower",
83 Self::Allowee => "allowee",
84 }
85 }
86
87 pub(crate) fn counterparty(self) -> Self {
89 match self {
90 Self::Allower => Self::Allowee,
91 Self::Allowee => Self::Allower,
92 }
93 }
94
95 pub(super) fn parse(value: &str) -> Result<Self> {
96 match value {
97 "allower" => Ok(Self::Allower),
98 "allowee" => Ok(Self::Allowee),
99 _ => Err(PaykitError::Validation(
100 "Allowance proposer_role must be allower or allowee".into(),
101 )),
102 }
103 }
104}
105
106impl fmt::Display for AllowanceRole {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.write_str(self.as_str())
109 }
110}
111
112#[derive(Clone, PartialEq, Eq)]
114pub struct AllowanceAmountRange {
115 minimum: String,
116 maximum: String,
117}
118
119impl AllowanceAmountRange {
120 pub fn new(minimum: impl Into<String>, maximum: impl Into<String>) -> Result<Self> {
122 let minimum = minimum.into();
123 let maximum = maximum.into();
124 validate_decimal_text(&minimum, "Allowance minimum")?;
125 validate_decimal_text(&maximum, "Allowance maximum")?;
126 if compare_decimals(&minimum, &maximum).is_gt() {
127 return Err(PaykitError::Validation(
128 "Allowance minimum must not exceed maximum".into(),
129 ));
130 }
131 Ok(Self { minimum, maximum })
132 }
133
134 pub fn minimum(&self) -> &str {
136 &self.minimum
137 }
138
139 pub fn maximum(&self) -> &str {
141 &self.maximum
142 }
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
147pub enum AllowancePeriodKind {
148 Anchored,
150 Rolling,
152}
153
154impl AllowancePeriodKind {
155 pub fn as_str(self) -> &'static str {
157 match self {
158 Self::Anchored => "anchored",
159 Self::Rolling => "rolling",
160 }
161 }
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
166pub enum AllowancePeriodUnit {
167 Minute,
169 Hour,
171 Day,
173 Week,
175 Month,
177 Year,
179}
180
181impl AllowancePeriodUnit {
182 pub fn as_str(self) -> &'static str {
184 match self {
185 Self::Minute => "minute",
186 Self::Hour => "hour",
187 Self::Day => "day",
188 Self::Week => "week",
189 Self::Month => "month",
190 Self::Year => "year",
191 }
192 }
193
194 pub(super) fn parse(value: &str) -> Result<Self> {
195 match value {
196 "minute" => Ok(Self::Minute),
197 "hour" => Ok(Self::Hour),
198 "day" => Ok(Self::Day),
199 "week" => Ok(Self::Week),
200 "month" => Ok(Self::Month),
201 "year" => Ok(Self::Year),
202 _ => Err(PaykitError::Validation(
203 "Allowance period unit is unsupported".into(),
204 )),
205 }
206 }
207}
208
209impl fmt::Display for AllowancePeriodUnit {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 f.write_str(self.as_str())
212 }
213}
214
215#[derive(Clone, PartialEq, Eq, Hash)]
219pub struct AllowancePeriod {
220 every: u64,
221 unit: AllowancePeriodUnit,
222 anchor: Option<String>,
223}
224
225impl AllowancePeriod {
226 pub fn anchored(
228 every: u64,
229 unit: AllowancePeriodUnit,
230 anchor: impl Into<String>,
231 ) -> Result<Self> {
232 if every == 0 {
233 return Err(PaykitError::Validation(
234 "Allowance period every must be positive".into(),
235 ));
236 }
237 let anchor = anchor.into();
238 parse_utc_timestamp(&anchor, "Allowance period anchor")?;
239 Ok(Self {
240 every,
241 unit,
242 anchor: Some(anchor),
243 })
244 }
245
246 pub fn rolling(every: u64, unit: AllowancePeriodUnit) -> Result<Self> {
251 if every == 0 {
252 return Err(PaykitError::Validation(
253 "Allowance period every must be positive".into(),
254 ));
255 }
256 if matches!(unit, AllowancePeriodUnit::Month | AllowancePeriodUnit::Year) {
257 return Err(PaykitError::Validation(
258 "rolling Allowance periods do not support month or year".into(),
259 ));
260 }
261 Ok(Self {
262 every,
263 unit,
264 anchor: None,
265 })
266 }
267
268 pub fn kind(&self) -> AllowancePeriodKind {
270 if self.anchor.is_some() {
271 AllowancePeriodKind::Anchored
272 } else {
273 AllowancePeriodKind::Rolling
274 }
275 }
276
277 pub fn every(&self) -> u64 {
279 self.every
280 }
281
282 pub fn unit(&self) -> AllowancePeriodUnit {
284 self.unit
285 }
286
287 pub fn anchor(&self) -> Option<&str> {
289 self.anchor.as_deref()
290 }
291}
292
293#[derive(Clone, PartialEq, Eq, Hash)]
295pub struct AllowancePeriodLimit {
296 amount_limit: Option<String>,
297 payment_count_limit: Option<u64>,
298 period: AllowancePeriod,
299}
300
301impl AllowancePeriodLimit {
302 pub fn new(
304 amount_limit: Option<String>,
305 payment_count_limit: Option<u64>,
306 period: AllowancePeriod,
307 ) -> Result<Self> {
308 if amount_limit.is_none() && payment_count_limit.is_none() {
309 return Err(PaykitError::Validation(
310 "Allowance period limit must configure an amount or payment count".into(),
311 ));
312 }
313 if let Some(amount) = &amount_limit {
314 validate_decimal_text(amount, "Allowance period amount_limit")?;
315 }
316 Ok(Self {
317 amount_limit,
318 payment_count_limit,
319 period,
320 })
321 }
322
323 pub fn amount_limit(&self) -> Option<&str> {
325 self.amount_limit.as_deref()
326 }
327
328 pub fn payment_count_limit(&self) -> Option<u64> {
330 self.payment_count_limit
331 }
332
333 pub fn period(&self) -> &AllowancePeriod {
335 &self.period
336 }
337}
338
339#[derive(Clone, PartialEq, Eq)]
341pub struct AllowanceTerms {
342 pub(super) asset: String,
343 pub(super) per_payment_amount: Option<AllowanceAmountRange>,
344 pub(super) period_limits: Vec<AllowancePeriodLimit>,
345 pub(super) lifetime_amount_limit: Option<String>,
346 pub(super) active_from: Option<String>,
347 pub(super) expires_at: Option<String>,
348 pub(super) allowed_payment_endpoint_identifiers: Option<Vec<PaymentEndpointIdentifier>>,
349}
350
351impl AllowanceTerms {
352 pub fn builder(asset: impl Into<String>) -> AllowanceTermsBuilder {
354 AllowanceTermsBuilder::new(asset)
355 }
356
357 pub fn asset(&self) -> &str {
359 &self.asset
360 }
361
362 pub fn per_payment_amount(&self) -> Option<&AllowanceAmountRange> {
364 self.per_payment_amount.as_ref()
365 }
366
367 pub fn period_limits(&self) -> &[AllowancePeriodLimit] {
369 &self.period_limits
370 }
371
372 pub fn lifetime_amount_limit(&self) -> Option<&str> {
374 self.lifetime_amount_limit.as_deref()
375 }
376
377 pub fn active_from(&self) -> Option<&str> {
379 self.active_from.as_deref()
380 }
381
382 pub fn expires_at(&self) -> Option<&str> {
384 self.expires_at.as_deref()
385 }
386
387 pub fn allowed_payment_endpoint_identifiers(&self) -> Option<&[PaymentEndpointIdentifier]> {
389 self.allowed_payment_endpoint_identifiers.as_deref()
390 }
391
392 pub(super) fn validate(&self) -> Result<()> {
393 validate_asset_text(&self.asset, "Allowance asset")?;
394 if let Some(limit) = &self.lifetime_amount_limit {
395 validate_decimal_text(limit, "Allowance lifetime_amount_limit")?;
396 }
397 validate_time_window(self.active_from.as_deref(), self.expires_at.as_deref())?;
398 validate_unique_period_limits(&self.period_limits)?;
399 validate_allowlist(self.allowed_payment_endpoint_identifiers.as_deref())?;
400 if self.per_payment_amount.is_none()
401 && !self
402 .period_limits
403 .iter()
404 .any(|limit| limit.amount_limit().is_some())
405 && self.lifetime_amount_limit.is_none()
406 && self.expires_at.is_none()
407 {
408 return Err(PaykitError::Validation(
409 "Allowance Terms must include an amount ceiling or expiry".into(),
410 ));
411 }
412 Ok(())
413 }
414}
415
416#[derive(Clone)]
420pub struct AllowanceTermsBuilder(AllowanceTerms);
421
422impl AllowanceTermsBuilder {
423 pub fn new(asset: impl Into<String>) -> Self {
425 Self(AllowanceTerms {
426 asset: asset.into(),
427 per_payment_amount: None,
428 period_limits: Vec::new(),
429 lifetime_amount_limit: None,
430 active_from: None,
431 expires_at: None,
432 allowed_payment_endpoint_identifiers: None,
433 })
434 }
435
436 pub fn per_payment_amount(mut self, range: AllowanceAmountRange) -> Self {
438 self.0.per_payment_amount = Some(range);
439 self
440 }
441
442 pub fn period_limits(mut self, limits: Vec<AllowancePeriodLimit>) -> Self {
444 self.0.period_limits = limits;
445 self
446 }
447
448 pub fn lifetime_amount_limit(mut self, limit: impl Into<String>) -> Self {
450 self.0.lifetime_amount_limit = Some(limit.into());
451 self
452 }
453
454 pub fn active_from(mut self, active_from: impl Into<String>) -> Self {
456 self.0.active_from = Some(active_from.into());
457 self
458 }
459
460 pub fn expires_at(mut self, expires_at: impl Into<String>) -> Self {
462 self.0.expires_at = Some(expires_at.into());
463 self
464 }
465
466 pub fn allowed_payment_endpoint_identifiers(
468 mut self,
469 identifiers: Vec<PaymentEndpointIdentifier>,
470 ) -> Self {
471 self.0.allowed_payment_endpoint_identifiers = Some(identifiers);
472 self
473 }
474
475 pub fn build(self) -> Result<AllowanceTerms> {
477 self.0.validate()?;
478 Ok(self.0)
479 }
480}
481
482macro_rules! event_header_accessors {
484 ($kind:expr) => {
485 pub fn version(&self) -> u8 {
487 ALLOWANCE_V1_VERSION
488 }
489
490 pub fn kind(&self) -> PrivateMessageKind {
492 $kind
493 }
494
495 pub fn event_id(&self) -> &EventId {
497 &self.event_id
498 }
499
500 pub fn allowance_id(&self) -> &AllowanceId {
502 &self.allowance_id
503 }
504 };
505}
506
507macro_rules! event_accessors {
509 ($kind:expr) => {
510 event_header_accessors!($kind);
511
512 pub fn proposal_event_id(&self) -> &EventId {
514 &self.proposal_event_id
515 }
516 };
517}
518
519#[derive(Clone, Debug, PartialEq, Eq)]
521pub struct AllowanceProposal {
522 event_id: EventId,
523 allowance_id: AllowanceId,
524 proposer_role: AllowanceRole,
525 terms: AllowanceTerms,
526}
527
528impl AllowanceProposal {
529 pub fn new(
531 event_id: EventId,
532 allowance_id: AllowanceId,
533 proposer_role: AllowanceRole,
534 terms: AllowanceTerms,
535 ) -> Self {
536 Self {
537 event_id,
538 allowance_id,
539 proposer_role,
540 terms,
541 }
542 }
543
544 event_header_accessors!(PrivateMessageKind::AllowanceProposal);
545
546 pub fn proposer_role(&self) -> AllowanceRole {
548 self.proposer_role
549 }
550
551 pub fn recipient_role(&self) -> AllowanceRole {
553 self.proposer_role.counterparty()
554 }
555
556 pub fn terms(&self) -> &AllowanceTerms {
558 &self.terms
559 }
560}
561
562#[derive(Clone, Debug, PartialEq, Eq)]
564pub struct AllowanceAcceptance {
565 event_id: EventId,
566 allowance_id: AllowanceId,
567 proposal_event_id: EventId,
568}
569
570impl AllowanceAcceptance {
571 pub fn new(
573 event_id: EventId,
574 allowance_id: AllowanceId,
575 proposal_event_id: EventId,
576 ) -> Result<Self> {
577 validate_distinct_event_ids(&event_id, &proposal_event_id, None)?;
578 Ok(Self {
579 event_id,
580 allowance_id,
581 proposal_event_id,
582 })
583 }
584
585 event_accessors!(PrivateMessageKind::AllowanceAcceptance);
586
587 pub fn validate_for_proposal(
589 &self,
590 proposal: &AllowanceProposal,
591 authenticated_sender_role: AllowanceRole,
592 ) -> Result<()> {
593 validate_response(
594 &self.allowance_id,
595 &self.proposal_event_id,
596 proposal,
597 authenticated_sender_role,
598 )
599 }
600}
601
602#[derive(Clone, Debug, PartialEq, Eq)]
604pub struct AllowanceRejection {
605 event_id: EventId,
606 allowance_id: AllowanceId,
607 proposal_event_id: EventId,
608}
609
610impl AllowanceRejection {
611 pub fn new(
613 event_id: EventId,
614 allowance_id: AllowanceId,
615 proposal_event_id: EventId,
616 ) -> Result<Self> {
617 validate_distinct_event_ids(&event_id, &proposal_event_id, None)?;
618 Ok(Self {
619 event_id,
620 allowance_id,
621 proposal_event_id,
622 })
623 }
624
625 event_accessors!(PrivateMessageKind::AllowanceRejection);
626
627 pub fn validate_for_proposal(
629 &self,
630 proposal: &AllowanceProposal,
631 authenticated_sender_role: AllowanceRole,
632 ) -> Result<()> {
633 validate_response(
634 &self.allowance_id,
635 &self.proposal_event_id,
636 proposal,
637 authenticated_sender_role,
638 )
639 }
640}
641
642#[derive(Clone, Debug, PartialEq, Eq)]
644pub struct AllowanceEnd {
645 event_id: EventId,
646 allowance_id: AllowanceId,
647 proposal_event_id: EventId,
648 acceptance_event_id: Option<EventId>,
649}
650
651impl AllowanceEnd {
652 pub fn withdrawal(
656 event_id: EventId,
657 allowance_id: AllowanceId,
658 proposal_event_id: EventId,
659 ) -> Result<Self> {
660 Self::new(event_id, allowance_id, proposal_event_id, None)
661 }
662
663 pub fn accepted(
665 event_id: EventId,
666 allowance_id: AllowanceId,
667 proposal_event_id: EventId,
668 acceptance_event_id: EventId,
669 ) -> Result<Self> {
670 Self::new(
671 event_id,
672 allowance_id,
673 proposal_event_id,
674 Some(acceptance_event_id),
675 )
676 }
677
678 pub(super) fn new(
679 event_id: EventId,
680 allowance_id: AllowanceId,
681 proposal_event_id: EventId,
682 acceptance_event_id: Option<EventId>,
683 ) -> Result<Self> {
684 validate_distinct_event_ids(&event_id, &proposal_event_id, acceptance_event_id.as_ref())?;
685 Ok(Self {
686 event_id,
687 allowance_id,
688 proposal_event_id,
689 acceptance_event_id,
690 })
691 }
692
693 event_accessors!(PrivateMessageKind::AllowanceEnd);
694
695 pub fn acceptance_event_id(&self) -> Option<&EventId> {
697 self.acceptance_event_id.as_ref()
698 }
699
700 pub fn validate_withdrawal_for_proposal(
702 &self,
703 proposal: &AllowanceProposal,
704 authenticated_sender_role: AllowanceRole,
705 ) -> Result<()> {
706 validate_common_correlation(&self.allowance_id, &self.proposal_event_id, proposal)?;
707 if self.acceptance_event_id.is_some() {
708 return Err(PaykitError::Validation(
709 "Allowance withdrawal must not reference an Acceptance Event".into(),
710 ));
711 }
712 if authenticated_sender_role != proposal.proposer_role {
713 return Err(PaykitError::Validation(
714 "Allowance withdrawal sender must be the proposal sender".into(),
715 ));
716 }
717 Ok(())
718 }
719
720 pub fn validate_for_accepted_allowance(
727 &self,
728 proposal: &AllowanceProposal,
729 acceptance: &AllowanceAcceptance,
730 acceptance_authenticated_sender_role: AllowanceRole,
731 ) -> Result<()> {
732 acceptance.validate_for_proposal(proposal, acceptance_authenticated_sender_role)?;
733 validate_common_correlation(&self.allowance_id, &self.proposal_event_id, proposal)?;
734 if self.acceptance_event_id.as_ref() != Some(&acceptance.event_id) {
735 return Err(PaykitError::Validation(
736 "Allowance End must reference the bound Acceptance Event".into(),
737 ));
738 }
739 Ok(())
740 }
741}
742
743#[derive(Clone, Debug, PartialEq, Eq)]
745pub enum AllowanceEvent {
746 Proposal(AllowanceProposal),
748 Acceptance(AllowanceAcceptance),
750 Rejection(AllowanceRejection),
752 End(AllowanceEnd),
754}
755
756impl AllowanceEvent {
757 pub fn kind(&self) -> PrivateMessageKind {
759 match self {
760 Self::Proposal(event) => event.kind(),
761 Self::Acceptance(event) => event.kind(),
762 Self::Rejection(event) => event.kind(),
763 Self::End(event) => event.kind(),
764 }
765 }
766
767 pub fn event_id(&self) -> &EventId {
769 match self {
770 Self::Proposal(event) => &event.event_id,
771 Self::Acceptance(event) => &event.event_id,
772 Self::Rejection(event) => &event.event_id,
773 Self::End(event) => &event.event_id,
774 }
775 }
776
777 pub fn allowance_id(&self) -> &AllowanceId {
779 match self {
780 Self::Proposal(event) => &event.allowance_id,
781 Self::Acceptance(event) => &event.allowance_id,
782 Self::Rejection(event) => &event.allowance_id,
783 Self::End(event) => &event.allowance_id,
784 }
785 }
786}
787
788#[derive(Clone, PartialEq, Eq)]
790pub struct AllowanceEventMessage {
791 pub(super) app_id: Option<crate::PaykitAppId>,
792 pub(super) kind: PrivateMessageKind,
793 pub(super) event_id: Option<EventId>,
794 pub(super) allowance_id: Option<AllowanceId>,
795 pub(super) raw_json: String,
796 pub(super) event: std::result::Result<AllowanceEvent, String>,
797}
798
799impl AllowanceEventMessage {
800 pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
802 self.app_id.as_ref()
803 }
804
805 pub fn kind(&self) -> PrivateMessageKind {
807 self.kind
808 }
809
810 pub fn is_valid(&self) -> bool {
812 self.event.is_ok()
813 }
814
815 pub fn parsed_event(&self) -> Option<&AllowanceEvent> {
817 self.event.as_ref().ok()
818 }
819
820 pub fn validation_error(&self) -> Option<&str> {
822 self.event.as_ref().err().map(String::as_str)
823 }
824
825 pub fn event_id(&self) -> Option<&EventId> {
827 self.event_id.as_ref()
828 }
829
830 pub fn allowance_id(&self) -> Option<&AllowanceId> {
832 self.allowance_id.as_ref()
833 }
834
835 pub fn raw_json(&self) -> &str {
839 &self.raw_json
840 }
841}
842
843impl fmt::Debug for AllowanceEventMessage {
844 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
845 f.debug_struct("AllowanceEventMessage")
846 .field("kind", &self.kind)
847 .field("event_id", &self.event_id)
848 .field("allowance_id", &self.allowance_id)
849 .field(
850 "raw_json",
851 &format_args!("<redacted:{} bytes>", self.raw_json.len()),
852 )
853 .field(
854 "parsed_kind",
855 &self.event.as_ref().ok().map(AllowanceEvent::kind),
856 )
857 .field("validation_error", &self.validation_error())
858 .finish()
859 }
860}
861
862fn validate_response(
863 allowance_id: &AllowanceId,
864 proposal_event_id: &EventId,
865 proposal: &AllowanceProposal,
866 authenticated_sender_role: AllowanceRole,
867) -> Result<()> {
868 validate_common_correlation(allowance_id, proposal_event_id, proposal)?;
869 if authenticated_sender_role != proposal.recipient_role() {
870 return Err(PaykitError::Validation(
871 "Allowance response sender must be the proposal recipient".into(),
872 ));
873 }
874 Ok(())
875}
876
877fn validate_distinct_event_ids(
878 event_id: &EventId,
879 proposal_event_id: &EventId,
880 acceptance_event_id: Option<&EventId>,
881) -> Result<()> {
882 if event_id == proposal_event_id
883 || acceptance_event_id.is_some_and(|acceptance_event_id| {
884 acceptance_event_id == event_id || acceptance_event_id == proposal_event_id
885 })
886 {
887 return Err(PaykitError::Validation(
888 "Allowance lifecycle Event IDs must be pairwise distinct".into(),
889 ));
890 }
891 Ok(())
892}
893
894fn validate_common_correlation(
895 allowance_id: &AllowanceId,
896 proposal_event_id: &EventId,
897 proposal: &AllowanceProposal,
898) -> Result<()> {
899 if allowance_id != &proposal.allowance_id || proposal_event_id != &proposal.event_id {
900 return Err(PaykitError::Validation(
901 "Allowance event does not reference the bound proposal".into(),
902 ));
903 }
904 Ok(())
905}
906
907fn validate_time_window(active_from: Option<&str>, expires_at: Option<&str>) -> Result<()> {
908 let active = active_from
909 .map(|value| parse_utc_timestamp(value, "Allowance active_from"))
910 .transpose()?;
911 let expires = expires_at
912 .map(|value| parse_utc_timestamp(value, "Allowance expires_at"))
913 .transpose()?;
914 if let (Some(active), Some(expires)) = (active, expires) {
915 if expires <= active {
916 return Err(PaykitError::Validation(
917 "Allowance expires_at must be after active_from".into(),
918 ));
919 }
920 }
921 Ok(())
922}
923
924fn validate_unique_period_limits(limits: &[AllowancePeriodLimit]) -> Result<()> {
925 if !all_unique(limits) {
926 return Err(PaykitError::Validation(
927 "Allowance period_limits must contain unique entries".into(),
928 ));
929 }
930 Ok(())
931}
932
933fn validate_allowlist(identifiers: Option<&[PaymentEndpointIdentifier]>) -> Result<()> {
934 let Some(identifiers) = identifiers else {
935 return Ok(());
936 };
937 if identifiers.is_empty() {
938 return Err(PaykitError::Validation(
939 "Allowance endpoint allowlist must not be empty".into(),
940 ));
941 }
942 if !all_unique(identifiers) {
943 return Err(PaykitError::Validation(
944 "Allowance endpoint allowlist must contain unique identifiers".into(),
945 ));
946 }
947 Ok(())
948}
949
950fn all_unique<T: Eq + Hash>(items: &[T]) -> bool {
951 let mut seen = HashSet::with_capacity(items.len());
952 items.iter().all(|item| seen.insert(item))
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use crate::allowance::test_fixtures::{minimal_proposal, proposal_with_terms};
959
960 #[test]
961 fn test_amount_range_compares_decimals_exactly() {
962 assert!(AllowanceAmountRange::new(
963 "999999999999999999999999.9",
964 "1000000000000000000000000"
965 )
966 .is_ok());
967 assert!(AllowanceAmountRange::new("1.000", "1").is_ok());
968 assert!(AllowanceAmountRange::new(".51", ".509").is_err());
969 assert!(AllowanceAmountRange::new("1", "-1").is_err());
970 }
971
972 #[test]
973 fn test_period_constructors_reject_invalid_shapes() {
974 assert!(AllowancePeriod::rolling(0, AllowancePeriodUnit::Day).is_err());
975 assert!(AllowancePeriod::rolling(1, AllowancePeriodUnit::Month).is_err());
976 assert!(AllowancePeriod::anchored(
977 1,
978 AllowancePeriodUnit::Month,
979 "2026-01-31T00:00:00+00:00"
980 )
981 .is_err());
982 }
983
984 #[test]
985 fn test_terms_reject_unconstrained_and_duplicate_rules() {
986 assert!(AllowanceTerms::builder("btc").build().is_err());
987
988 let period = AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap();
989 let limit = AllowancePeriodLimit::new(Some("1".into()), None, period).unwrap();
990 assert!(AllowanceTerms::builder("btc")
991 .period_limits(vec![limit.clone(), limit])
992 .build()
993 .is_err());
994
995 let endpoint = PaymentEndpointIdentifier::new("btc-lightning-bolt12").unwrap();
996 assert!(AllowanceTerms::builder("btc")
997 .allowed_payment_endpoint_identifiers(vec![endpoint.clone(), endpoint])
998 .build()
999 .is_err());
1000 }
1001
1002 #[test]
1003 fn test_terms_require_amount_ceiling_or_expiry() {
1004 let endpoint = PaymentEndpointIdentifier::new("btc-lightning-bolt12").unwrap();
1005 let period = AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap();
1006 let count_only = AllowancePeriodLimit::new(None, Some(0), period.clone()).unwrap();
1007 let builders = [
1008 AllowanceTerms::builder("btc").active_from("2026-01-01T00:00:00Z"),
1009 AllowanceTerms::builder("btc")
1010 .allowed_payment_endpoint_identifiers(vec![endpoint.clone()]),
1011 AllowanceTerms::builder("btc").period_limits(vec![count_only.clone()]),
1012 AllowanceTerms::builder("btc")
1013 .active_from("2026-01-01T00:00:00Z")
1014 .allowed_payment_endpoint_identifiers(vec![endpoint])
1015 .period_limits(vec![count_only]),
1016 ];
1017 for builder in builders {
1018 assert!(matches!(builder.build(), Err(PaykitError::Validation(_))));
1019 }
1020
1021 for builder in [
1022 AllowanceTerms::builder("btc")
1023 .per_payment_amount(AllowanceAmountRange::new("0", "0").unwrap()),
1024 AllowanceTerms::builder("btc").period_limits(vec![AllowancePeriodLimit::new(
1025 Some("0".into()),
1026 None,
1027 period,
1028 )
1029 .unwrap()]),
1030 AllowanceTerms::builder("btc").lifetime_amount_limit("0"),
1031 AllowanceTerms::builder("btc").expires_at("2000-01-01T00:00:00Z"),
1032 ] {
1033 assert!(builder.build().is_ok());
1034 }
1035 }
1036
1037 #[test]
1038 fn test_response_and_end_correlation_checks_roles_and_ids() {
1039 let proposal = minimal_proposal();
1040 let acceptance = AllowanceAcceptance::new(
1041 EventId::new_v4(),
1042 proposal.allowance_id.clone(),
1043 proposal.event_id.clone(),
1044 )
1045 .unwrap();
1046 assert!(acceptance
1047 .validate_for_proposal(&proposal, AllowanceRole::Allowee)
1048 .is_ok());
1049 assert!(acceptance
1050 .validate_for_proposal(&proposal, AllowanceRole::Allower)
1051 .is_err());
1052 assert!(AllowanceAcceptance::new(
1053 EventId::new_v4(),
1054 AllowanceId::new_v4(),
1055 proposal.event_id.clone(),
1056 )
1057 .unwrap()
1058 .validate_for_proposal(&proposal, AllowanceRole::Allowee)
1059 .is_err());
1060 assert!(AllowanceAcceptance::new(
1061 EventId::new_v4(),
1062 proposal.allowance_id.clone(),
1063 EventId::new_v4(),
1064 )
1065 .unwrap()
1066 .validate_for_proposal(&proposal, AllowanceRole::Allowee)
1067 .is_err());
1068
1069 let end = AllowanceEnd::accepted(
1070 EventId::new_v4(),
1071 proposal.allowance_id.clone(),
1072 proposal.event_id.clone(),
1073 acceptance.event_id.clone(),
1074 )
1075 .unwrap();
1076 assert!(end
1077 .validate_for_accepted_allowance(&proposal, &acceptance, AllowanceRole::Allowee)
1078 .is_ok());
1079 assert!(end
1080 .validate_for_accepted_allowance(&proposal, &acceptance, AllowanceRole::Allower)
1081 .is_err());
1082
1083 let mismatched_end = AllowanceEnd::accepted(
1084 EventId::new_v4(),
1085 proposal.allowance_id.clone(),
1086 proposal.event_id.clone(),
1087 EventId::new_v4(),
1088 )
1089 .unwrap();
1090 assert!(mismatched_end
1091 .validate_for_accepted_allowance(&proposal, &acceptance, AllowanceRole::Allowee)
1092 .is_err());
1093
1094 let withdrawal = AllowanceEnd::withdrawal(
1095 EventId::new_v4(),
1096 proposal.allowance_id.clone(),
1097 proposal.event_id.clone(),
1098 )
1099 .unwrap();
1100 assert!(withdrawal
1101 .validate_withdrawal_for_proposal(&proposal, AllowanceRole::Allower)
1102 .is_ok());
1103 assert!(withdrawal
1104 .validate_withdrawal_for_proposal(&proposal, AllowanceRole::Allowee)
1105 .is_err());
1106 assert!(end
1107 .validate_withdrawal_for_proposal(&proposal, AllowanceRole::Allower)
1108 .is_err());
1109 }
1110
1111 #[test]
1112 fn test_response_constructors_reject_reused_proposal_event_id() {
1113 let proposal = minimal_proposal();
1114
1115 assert!(matches!(
1116 AllowanceAcceptance::new(
1117 proposal.event_id.clone(),
1118 proposal.allowance_id.clone(),
1119 proposal.event_id.clone(),
1120 ),
1121 Err(PaykitError::Validation(_))
1122 ));
1123 assert!(matches!(
1124 AllowanceRejection::new(
1125 proposal.event_id.clone(),
1126 proposal.allowance_id.clone(),
1127 proposal.event_id.clone(),
1128 ),
1129 Err(PaykitError::Validation(_))
1130 ));
1131 }
1132
1133 #[test]
1134 fn test_end_constructors_require_pairwise_distinct_event_ids() {
1135 let proposal = minimal_proposal();
1136 let acceptance_event_id = EventId::new_v4();
1137
1138 assert!(matches!(
1139 AllowanceEnd::withdrawal(
1140 proposal.event_id.clone(),
1141 proposal.allowance_id.clone(),
1142 proposal.event_id.clone(),
1143 ),
1144 Err(PaykitError::Validation(_))
1145 ));
1146 for (event_id, proposal_event_id, acceptance_event_id) in [
1147 (
1148 proposal.event_id.clone(),
1149 proposal.event_id.clone(),
1150 acceptance_event_id.clone(),
1151 ),
1152 (
1153 acceptance_event_id.clone(),
1154 proposal.event_id.clone(),
1155 acceptance_event_id.clone(),
1156 ),
1157 (
1158 EventId::new_v4(),
1159 proposal.event_id.clone(),
1160 proposal.event_id.clone(),
1161 ),
1162 ] {
1163 assert!(matches!(
1164 AllowanceEnd::accepted(
1165 event_id,
1166 proposal.allowance_id.clone(),
1167 proposal_event_id,
1168 acceptance_event_id,
1169 ),
1170 Err(PaykitError::Validation(_))
1171 ));
1172 }
1173 }
1174
1175 #[test]
1176 fn test_debug_redacts_terms() {
1177 let terms = AllowanceTerms::builder("SENTINEL_ASSET")
1178 .lifetime_amount_limit("123456789")
1179 .build()
1180 .unwrap();
1181 let debug = format!("{:?}", proposal_with_terms(terms));
1182 assert!(!debug.contains("SENTINEL_ASSET"));
1183 assert!(!debug.contains("123456789"));
1184 }
1185}