Skip to main content

paykit_lib/allowance/
types.rs

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
12/// Protocol version of every Allowance Event Message this module produces.
13const ALLOWANCE_V1_VERSION: u8 = 1;
14
15/// SECURITY / REDACTION: Allowance Terms are private counterparty data and
16/// must never reach Debug or log output, so every terms-bearing type prints a
17/// fixed `Name(<redacted>)` marker instead of its fields.
18macro_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/// UUID-v4 identifier shared by one Allowance lifecycle.
37#[derive(Clone, Debug, PartialEq, Eq, Hash)]
38pub struct AllowanceId(String);
39
40impl AllowanceId {
41    /// Create an Allowance ID, canonicalizing a valid UUID-v4 spelling.
42    pub fn new(id: impl Into<String>) -> Result<Self> {
43        validate_uuid_v4(id.into(), "Allowance ID").map(Self)
44    }
45
46    /// Generate a fresh Allowance ID.
47    pub fn new_v4() -> Self {
48        Self(uuid::Uuid::new_v4().to_string())
49    }
50
51    /// Access the canonical lowercase, hyphenated UUID string.
52    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/// A party's role in one Allowance.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum AllowanceRole {
72    /// Party granting authority for automatic payment handling.
73    Allower,
74    /// Authenticated Payment Request sender whose requests may use the authority.
75    Allowee,
76}
77
78impl AllowanceRole {
79    /// Return the canonical wire spelling.
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Self::Allower => "allower",
83            Self::Allowee => "allowee",
84        }
85    }
86
87    /// Return the counterparty role.
88    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/// Inclusive per-payment amount range, preserving decimal wire spellings.
113#[derive(Clone, PartialEq, Eq)]
114pub struct AllowanceAmountRange {
115    minimum: String,
116    maximum: String,
117}
118
119impl AllowanceAmountRange {
120    /// Create an inclusive range whose minimum is no greater than its maximum.
121    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    /// Access the minimum decimal spelling.
135    pub fn minimum(&self) -> &str {
136        &self.minimum
137    }
138
139    /// Access the maximum decimal spelling.
140    pub fn maximum(&self) -> &str {
141        &self.maximum
142    }
143}
144
145/// Usage-period shape used by an Allowance limit.
146#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
147pub enum AllowancePeriodKind {
148    /// Periods aligned to a fixed UTC anchor.
149    Anchored,
150    /// Fixed-duration window ending at the evaluation instant.
151    Rolling,
152}
153
154impl AllowancePeriodKind {
155    /// Return the canonical wire spelling.
156    pub fn as_str(self) -> &'static str {
157        match self {
158            Self::Anchored => "anchored",
159            Self::Rolling => "rolling",
160        }
161    }
162}
163
164/// Unit used by an Allowance period.
165#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
166pub enum AllowancePeriodUnit {
167    /// Minute interval.
168    Minute,
169    /// Hour interval.
170    Hour,
171    /// Day interval.
172    Day,
173    /// Week interval.
174    Week,
175    /// Calendar-month interval (anchored periods only).
176    Month,
177    /// Calendar-year interval (anchored periods only).
178    Year,
179}
180
181impl AllowancePeriodUnit {
182    /// Return the canonical singular wire spelling.
183    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/// Validated anchored or rolling period used by an Allowance limit.
216///
217/// The period is anchored exactly when `anchor` is present.
218#[derive(Clone, PartialEq, Eq, Hash)]
219pub struct AllowancePeriod {
220    every: u64,
221    unit: AllowancePeriodUnit,
222    anchor: Option<String>,
223}
224
225impl AllowancePeriod {
226    /// Create an anchored period.
227    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    /// Create a rolling fixed-duration period.
247    ///
248    /// Rolling month and year periods are rejected because their duration is
249    /// calendar-dependent.
250    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    /// Access the period kind.
269    pub fn kind(&self) -> AllowancePeriodKind {
270        if self.anchor.is_some() {
271            AllowancePeriodKind::Anchored
272        } else {
273            AllowancePeriodKind::Rolling
274        }
275    }
276
277    /// Access the positive interval multiplier.
278    pub fn every(&self) -> u64 {
279        self.every
280    }
281
282    /// Access the interval unit.
283    pub fn unit(&self) -> AllowancePeriodUnit {
284        self.unit
285    }
286
287    /// Access the UTC anchor for an anchored period.
288    pub fn anchor(&self) -> Option<&str> {
289        self.anchor.as_deref()
290    }
291}
292
293/// Amount and/or payment-count ceiling applied over one period.
294#[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    /// Create a period limit. At least one ceiling must be present.
303    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    /// Access the optional amount ceiling decimal spelling.
324    pub fn amount_limit(&self) -> Option<&str> {
325        self.amount_limit.as_deref()
326    }
327
328    /// Access the optional payment-count ceiling.
329    pub fn payment_count_limit(&self) -> Option<u64> {
330        self.payment_count_limit
331    }
332
333    /// Access the period definition.
334    pub fn period(&self) -> &AllowancePeriod {
335        &self.period
336    }
337}
338
339/// Immutable constraints proposed for one Allowance.
340#[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    /// Start building Allowance Terms for an exact, case-sensitive asset.
353    pub fn builder(asset: impl Into<String>) -> AllowanceTermsBuilder {
354        AllowanceTermsBuilder::new(asset)
355    }
356
357    /// Access the exact asset string.
358    pub fn asset(&self) -> &str {
359        &self.asset
360    }
361
362    /// Access the optional inclusive per-payment range.
363    pub fn per_payment_amount(&self) -> Option<&AllowanceAmountRange> {
364        self.per_payment_amount.as_ref()
365    }
366
367    /// Access all independently applicable period limits.
368    pub fn period_limits(&self) -> &[AllowancePeriodLimit] {
369        &self.period_limits
370    }
371
372    /// Access the optional lifetime amount ceiling decimal spelling.
373    pub fn lifetime_amount_limit(&self) -> Option<&str> {
374        self.lifetime_amount_limit.as_deref()
375    }
376
377    /// Access the optional inclusive first eligible instant.
378    pub fn active_from(&self) -> Option<&str> {
379        self.active_from.as_deref()
380    }
381
382    /// Access the optional exclusive first ineligible instant.
383    pub fn expires_at(&self) -> Option<&str> {
384        self.expires_at.as_deref()
385    }
386
387    /// Access the optional non-empty Payment Endpoint Identifier allowlist.
388    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/// Builder for immutable [`AllowanceTerms`].
417///
418/// Holds unvalidated terms; [`AllowanceTermsBuilder::build`] validates them.
419#[derive(Clone)]
420pub struct AllowanceTermsBuilder(AllowanceTerms);
421
422impl AllowanceTermsBuilder {
423    /// Create a builder for an exact, case-sensitive asset.
424    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    /// Set the inclusive per-payment amount range.
437    pub fn per_payment_amount(mut self, range: AllowanceAmountRange) -> Self {
438        self.0.per_payment_amount = Some(range);
439        self
440    }
441
442    /// Replace the independently applicable period limits.
443    pub fn period_limits(mut self, limits: Vec<AllowancePeriodLimit>) -> Self {
444        self.0.period_limits = limits;
445        self
446    }
447
448    /// Set the lifetime amount ceiling.
449    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    /// Set the inclusive first eligible instant.
455    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    /// Set the exclusive first ineligible instant.
461    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    /// Set the exact Payment Endpoint Identifier allowlist.
467    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    /// Validate all fields and build immutable Allowance Terms.
476    pub fn build(self) -> Result<AllowanceTerms> {
477        self.0.validate()?;
478        Ok(self.0)
479    }
480}
481
482/// Accessors shared by every V1 Allowance event header.
483macro_rules! event_header_accessors {
484    ($kind:expr) => {
485        /// Access the protocol version.
486        pub fn version(&self) -> u8 {
487            ALLOWANCE_V1_VERSION
488        }
489
490        /// Access the Private Message Kind.
491        pub fn kind(&self) -> PrivateMessageKind {
492            $kind
493        }
494
495        /// Access the Event ID.
496        pub fn event_id(&self) -> &EventId {
497            &self.event_id
498        }
499
500        /// Access the Allowance ID.
501        pub fn allowance_id(&self) -> &AllowanceId {
502            &self.allowance_id
503        }
504    };
505}
506
507/// Header accessors plus the Proposal reference shared by response events.
508macro_rules! event_accessors {
509    ($kind:expr) => {
510        event_header_accessors!($kind);
511
512        /// Access the referenced Proposal Event ID.
513        pub fn proposal_event_id(&self) -> &EventId {
514            &self.proposal_event_id
515        }
516    };
517}
518
519/// Proposal Event Message for exact Allowance Terms.
520#[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    /// Create a V1 Allowance Proposal.
530    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    /// Access the authenticated proposal sender's assigned role.
547    pub fn proposer_role(&self) -> AllowanceRole {
548        self.proposer_role
549    }
550
551    /// Access the proposal recipient's assigned role.
552    pub fn recipient_role(&self) -> AllowanceRole {
553        self.proposer_role.counterparty()
554    }
555
556    /// Access the immutable Allowance Terms.
557    pub fn terms(&self) -> &AllowanceTerms {
558        &self.terms
559    }
560}
561
562/// Acceptance Event Message correlated to one Allowance Proposal.
563#[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    /// Create a V1 Allowance Acceptance with a distinct causal Event ID.
572    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    /// Check causal references and that the authenticated sender is the proposal recipient.
588    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/// Rejection Event Message correlated to one Allowance Proposal.
603#[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    /// Create a V1 Allowance Rejection with a distinct causal Event ID.
612    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    /// Check causal references and that the authenticated sender is the proposal recipient.
628    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/// Terminal End Event Message for a proposed or accepted Allowance.
643#[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    /// Create a proposal withdrawal with a null Acceptance Event ID.
653    ///
654    /// The End and Proposal Event IDs must be distinct.
655    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    /// Create an End for accepted authority with pairwise-distinct Event IDs.
664    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    /// Access the Acceptance Event ID, or `None` for a proposal withdrawal.
696    pub fn acceptance_event_id(&self) -> Option<&EventId> {
697        self.acceptance_event_id.as_ref()
698    }
699
700    /// Validate a proposal withdrawal by the authenticated proposal sender.
701    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    /// Validate accepted authority and its causal End references.
721    ///
722    /// The Acceptance sender must be the proposal recipient. Accepted authority
723    /// may be ended by either party, so the caller remains responsible for
724    /// establishing that the End arrived on the proposal's exact authenticated
725    /// Encrypted Link.
726    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/// Any V1 Allowance lifecycle Event Message.
744#[derive(Clone, Debug, PartialEq, Eq)]
745pub enum AllowanceEvent {
746    /// Proposal event.
747    Proposal(AllowanceProposal),
748    /// Acceptance event.
749    Acceptance(AllowanceAcceptance),
750    /// Rejection event.
751    Rejection(AllowanceRejection),
752    /// End event.
753    End(AllowanceEnd),
754}
755
756impl AllowanceEvent {
757    /// Access the Private Message Kind.
758    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    /// Access the Event ID.
768    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    /// Access the Allowance ID.
778    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/// A recognized Allowance Event Message plus its redacted parse result.
789#[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    /// Access the source Paykit App attribution, when valid.
801    pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
802        self.app_id.as_ref()
803    }
804
805    /// Access the recognized Private Message Kind.
806    pub fn kind(&self) -> PrivateMessageKind {
807        self.kind
808    }
809
810    /// Whether structural parsing and validation succeeded.
811    pub fn is_valid(&self) -> bool {
812        self.event.is_ok()
813    }
814
815    /// Access the parsed event when validation succeeded.
816    pub fn parsed_event(&self) -> Option<&AllowanceEvent> {
817        self.event.as_ref().ok()
818    }
819
820    /// Access the redacted validation error when validation failed.
821    pub fn validation_error(&self) -> Option<&str> {
822        self.event.as_ref().err().map(String::as_str)
823    }
824
825    /// Access the Event ID when the top-level value is a canonical UUID-v4.
826    pub fn event_id(&self) -> Option<&EventId> {
827        self.event_id.as_ref()
828    }
829
830    /// Access the Allowance ID when the top-level value is a canonical UUID-v4.
831    pub fn allowance_id(&self) -> Option<&AllowanceId> {
832        self.allowance_id.as_ref()
833    }
834
835    /// Access the exact raw JSON plaintext for durable storage.
836    ///
837    /// Treat this value as private and never log it.
838    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}