Skip to main content

paykit_lib/payment_request/
types.rs

1use std::{collections::HashMap, fmt};
2
3use serde_json::{Map as JsonMap, Value as JsonValue};
4
5use crate::{
6    validation::{parse_utc_timestamp, validate_uuid_v4},
7    AllowanceId, EventId, PaykitError, PaymentAmount, PaymentEndpointIdentifier,
8    PaymentEndpointPayload, PaymentReference, PrivateMessageKind, Result,
9};
10
11use super::{PaymentConversion, PaymentConversionQuote, PaymentDeadline};
12
13/// UUID-v4 identifier for one Payment Request.
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct PaymentRequestId(String);
16
17impl PaymentRequestId {
18    /// Create a Payment Request ID from a UUID-v4 string.
19    pub fn new(id: impl Into<String>) -> Result<Self> {
20        validate_uuid_v4(id.into(), "Payment Request ID").map(Self)
21    }
22
23    /// Generate a fresh Payment Request ID.
24    pub fn new_v4() -> Self {
25        Self(uuid::Uuid::new_v4().to_string())
26    }
27
28    /// Access the canonical UUID string.
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32}
33
34impl std::fmt::Display for PaymentRequestId {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.write_str(&self.0)
37    }
38}
39
40impl AsRef<str> for PaymentRequestId {
41    fn as_ref(&self) -> &str {
42        &self.0
43    }
44}
45
46/// Recurrence unit for recurring Payment Requests.
47///
48/// This enum is intentionally exhaustive. Adding a variant must produce
49/// compile-time failures in canonical wire serialization and SDK conversion
50/// matches until the new Recurrence unit is mapped explicitly. Do not add
51/// `#[non_exhaustive]` without a coordinated team decision.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum RecurrenceUnit {
54    /// Minute-based recurrence.
55    Minute,
56    /// Hour-based recurrence.
57    Hour,
58    /// Day-based recurrence.
59    Day,
60    /// Week-based recurrence.
61    Week,
62    /// Month-based recurrence.
63    Month,
64    /// Year-based recurrence.
65    Year,
66}
67
68impl RecurrenceUnit {
69    pub(super) fn as_str(self) -> &'static str {
70        match self {
71            Self::Minute => "minute",
72            Self::Hour => "hour",
73            Self::Day => "day",
74            Self::Week => "week",
75            Self::Month => "month",
76            Self::Year => "year",
77        }
78    }
79
80    pub(super) fn parse(value: &str) -> Result<Self> {
81        match value {
82            "minute" => Ok(Self::Minute),
83            "hour" => Ok(Self::Hour),
84            "day" => Ok(Self::Day),
85            "week" => Ok(Self::Week),
86            "month" => Ok(Self::Month),
87            "year" => Ok(Self::Year),
88            // SECURITY / REDACTION: from the wire parser `value` is decrypted
89            // private-message plaintext, and this message becomes persisted,
90            // FFI-visible error text, so the value is not echoed.
91            _ => Err(PaykitError::Validation(
92                "unsupported Recurrence unit".into(),
93            )),
94        }
95    }
96}
97
98/// Schedule object for a recurring Payment Request.
99///
100/// Fields cannot be mutated after validated construction.
101///
102/// ```compile_fail,E0616
103/// fn cannot_mutate(mut value: paykit_lib::Recurrence) {
104///     value.every = 0;
105/// }
106/// ```
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct Recurrence {
109    /// Positive interval count.
110    pub(super) every: u32,
111    /// Recurrence unit.
112    pub(super) unit: RecurrenceUnit,
113    /// RFC3339 UTC timestamp using `Z`.
114    pub(super) starts_at: String,
115    /// RFC3339 UTC timestamp using `Z`.
116    pub(super) anchor: String,
117    /// Optional RFC3339 UTC timestamp using `Z`, after `starts_at` when
118    /// present.
119    pub(super) ends_at: Option<String>,
120}
121
122/// Payment Request terms set by the payee.
123///
124/// Fields cannot be mutated after validated construction.
125///
126/// ```compile_fail,E0616
127/// fn cannot_mutate(mut value: paykit_lib::PaymentRequestTerms) {
128///     value.accepted_payment_endpoint_identifiers = Vec::new();
129/// }
130/// ```
131#[derive(Clone, PartialEq)]
132pub struct PaymentRequestTerms {
133    /// Requested amount.
134    pub(super) amount: PaymentAmount,
135    /// Payee-provided correlation value copied into Payment Proof messages.
136    pub(super) payment_reference: PaymentReference,
137    /// Proposal expiry before acceptance. `None` means no protocol-level
138    /// proposal expiry.
139    pub(super) proposal_expires_at: Option<String>,
140    /// Optional recurrence. `None` means one-time request.
141    pub(super) recurrence: Option<Recurrence>,
142    /// Accepted Payment Endpoint Identifiers.
143    pub(super) accepted_payment_endpoint_identifiers: Vec<PaymentEndpointIdentifier>,
144    /// Immutable Payment Endpoints owned by `required_app_id`, when bound.
145    pub(super) payment_endpoints:
146        Option<HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>>,
147    /// Optional conversion policy; absence leaves conversion to wallet policy.
148    pub(super) conversion: Option<PaymentConversion>,
149    /// Optional deadline for actual payment, independent of proposal acceptance.
150    pub(super) payment_deadline: Option<PaymentDeadline>,
151    /// Payee application whose Payment Endpoint must be paid, when constrained.
152    pub(super) required_app_id: Option<crate::PaykitAppId>,
153    /// Application-specific JSON metadata.
154    pub(super) metadata: JsonMap<String, JsonValue>,
155}
156
157impl fmt::Debug for PaymentRequestTerms {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.debug_struct("PaymentRequestTerms")
160            .field("amount", &"<redacted>")
161            .field("payment_reference", &"<redacted>")
162            .field("proposal_expires_at", &self.proposal_expires_at)
163            .field("recurrence", &self.recurrence)
164            .field("conversion", &self.conversion)
165            .field("payment_deadline", &self.payment_deadline)
166            .field(
167                "accepted_payment_endpoint_identifiers",
168                &self.accepted_payment_endpoint_identifiers,
169            )
170            .field("required_app_id", &self.required_app_id)
171            .field(
172                "payment_endpoints",
173                &self.payment_endpoints.as_ref().map(|_| "<redacted>"),
174            )
175            .field(
176                "metadata",
177                &format_args!("<redacted:{} fields>", self.metadata.len()),
178            )
179            .finish()
180    }
181}
182
183/// Time interval a recurring Payment Proof applies to.
184///
185/// # Validation
186///
187/// [`BillingPeriod::new`] validates the interval before construction. Payment
188/// Proof and Receipt parsers use the same rules, which require
189/// `starts_at` and `ends_at` to be RFC3339 timestamps with a `Z` suffix and
190/// `ends_at` to be strictly later than `starts_at`.
191///
192/// Receipt Access still requires [`ReceiptAccess::validate`](crate::ReceiptAccess::validate)
193/// for its request context. A valid interval alone does not establish the
194/// associated Payment Request ID.
195///
196/// Fields cannot be mutated after validated construction.
197///
198/// ```compile_fail,E0616
199/// fn cannot_mutate(mut value: paykit_lib::BillingPeriod) {
200///     value.ends_at = "invalid".into();
201/// }
202/// ```
203#[derive(Clone, Debug, PartialEq, Eq)]
204pub struct BillingPeriod {
205    /// RFC3339 UTC timestamp using `Z`.
206    pub(super) starts_at: String,
207    /// RFC3339 UTC timestamp using `Z`, after `starts_at`.
208    pub(super) ends_at: String,
209}
210
211/// `paykit.payment_request` Event Message.
212///
213/// Fields cannot be mutated after validated construction.
214///
215/// ```compile_fail,E0616
216/// fn cannot_mutate(mut value: paykit_lib::PaymentRequest) {
217///     value.version = 0;
218/// }
219/// ```
220#[derive(Clone, PartialEq)]
221pub struct PaymentRequest {
222    /// Message version. Currently always `1`.
223    pub(super) version: u8,
224    /// Private message kind. Currently [`PrivateMessageKind::PaymentRequest`].
225    pub(super) kind: PrivateMessageKind,
226    /// Event ID for idempotent processing.
227    pub(super) event_id: EventId,
228    /// Stable Payment Request ID.
229    pub(super) payment_request_id: PaymentRequestId,
230    /// Immutable request terms.
231    pub(super) request: PaymentRequestTerms,
232}
233
234impl fmt::Debug for PaymentRequest {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.debug_struct("PaymentRequest")
237            .field("version", &self.version)
238            .field("kind", &self.kind)
239            .field("event_id", &self.event_id)
240            .field("payment_request_id", &self.payment_request_id)
241            .field("request", &self.request)
242            .finish()
243    }
244}
245
246impl PaymentRequest {
247    pub(super) fn validate(&self) -> Result<()> {
248        crate::validation::validate_outgoing_version_kind(
249            self.version,
250            self.kind,
251            PrivateMessageKind::PaymentRequest,
252            "Payment Request",
253        )?;
254        self.request.validate()
255    }
256
257    /// Construct a Payment Request proposal using protocol version 1.
258    pub fn new(
259        event_id: EventId,
260        payment_request_id: PaymentRequestId,
261        request: PaymentRequestTerms,
262    ) -> Self {
263        Self {
264            version: 1,
265            kind: PrivateMessageKind::PaymentRequest,
266            event_id,
267            payment_request_id,
268            request,
269        }
270    }
271}
272
273/// `paykit.payment_request_acceptance` Event Message.
274///
275/// Fields cannot be mutated after validated construction.
276///
277/// ```compile_fail,E0616
278/// fn cannot_mutate(mut value: paykit_lib::PaymentRequestAcceptance) {
279///     value.kind = paykit_lib::PrivateMessageKind::PaymentProof;
280/// }
281/// ```
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub struct PaymentRequestAcceptance {
284    /// Message version. Currently always `1`.
285    pub(super) version: u8,
286    /// Private message kind. Currently [`PrivateMessageKind::PaymentRequestAcceptance`].
287    pub(super) kind: PrivateMessageKind,
288    /// Event ID for idempotent processing.
289    pub(super) event_id: EventId,
290    /// Stable Payment Request ID.
291    pub(super) payment_request_id: PaymentRequestId,
292}
293
294impl PaymentRequestAcceptance {
295    /// Construct a Payment Request acceptance using protocol version 1.
296    pub fn new(event_id: EventId, payment_request_id: PaymentRequestId) -> Self {
297        Self {
298            version: 1,
299            kind: PrivateMessageKind::PaymentRequestAcceptance,
300            event_id,
301            payment_request_id,
302        }
303    }
304}
305
306/// `paykit.payment_request_rejection` Event Message.
307///
308/// Fields cannot be mutated after validated construction.
309///
310/// ```compile_fail,E0616
311/// fn cannot_mutate(mut value: paykit_lib::PaymentRequestRejection) {
312///     value.version = 0;
313/// }
314/// ```
315#[derive(Clone, Debug, PartialEq, Eq)]
316pub struct PaymentRequestRejection {
317    /// Message version. Currently always `1`.
318    pub(super) version: u8,
319    /// Private message kind. Currently [`PrivateMessageKind::PaymentRequestRejection`].
320    pub(super) kind: PrivateMessageKind,
321    /// Event ID for idempotent processing.
322    pub(super) event_id: EventId,
323    /// Stable Payment Request ID.
324    pub(super) payment_request_id: PaymentRequestId,
325    /// Optional informational reason.
326    pub(super) reason: Option<String>,
327}
328
329impl PaymentRequestRejection {
330    /// Construct a Payment Request rejection using protocol version 1.
331    pub fn new(
332        event_id: EventId,
333        payment_request_id: PaymentRequestId,
334        reason: Option<String>,
335    ) -> Self {
336        Self {
337            version: 1,
338            kind: PrivateMessageKind::PaymentRequestRejection,
339            event_id,
340            payment_request_id,
341            reason,
342        }
343    }
344}
345
346/// `paykit.payment_request_cancellation` Event Message.
347///
348/// Fields cannot be mutated after validated construction.
349///
350/// ```compile_fail,E0616
351/// fn cannot_mutate(mut value: paykit_lib::PaymentRequestCancellation) {
352///     value.version = 0;
353/// }
354/// ```
355#[derive(Clone, Debug, PartialEq, Eq)]
356pub struct PaymentRequestCancellation {
357    /// Message version. Currently always `1`.
358    pub(super) version: u8,
359    /// Private message kind. Currently [`PrivateMessageKind::PaymentRequestCancellation`].
360    pub(super) kind: PrivateMessageKind,
361    /// Event ID for idempotent processing.
362    pub(super) event_id: EventId,
363    /// Stable Payment Request ID.
364    pub(super) payment_request_id: PaymentRequestId,
365    /// Optional informational reason.
366    pub(super) reason: Option<String>,
367}
368
369impl PaymentRequestCancellation {
370    /// Construct a Payment Request cancellation using protocol version 1.
371    pub fn new(
372        event_id: EventId,
373        payment_request_id: PaymentRequestId,
374        reason: Option<String>,
375    ) -> Self {
376        Self {
377            version: 1,
378            kind: PrivateMessageKind::PaymentRequestCancellation,
379            event_id,
380            payment_request_id,
381            reason,
382        }
383    }
384}
385
386/// `paykit.payment_proof` Event Message.
387///
388/// Fields cannot be mutated after validated construction.
389///
390/// ```compile_fail,E0616
391/// fn cannot_mutate(mut value: paykit_lib::PaymentProof) {
392///     value.kind = paykit_lib::PrivateMessageKind::PaymentRequest;
393/// }
394/// ```
395#[derive(Clone, PartialEq)]
396pub struct PaymentProof {
397    /// Message version. Currently always `1`.
398    pub(super) version: u8,
399    /// Private message kind. Currently [`PrivateMessageKind::PaymentProof`].
400    pub(super) kind: PrivateMessageKind,
401    /// Event ID for idempotent processing.
402    pub(super) event_id: EventId,
403    /// Stable Payment Request ID.
404    pub(super) payment_request_id: PaymentRequestId,
405    /// Payment Reference copied from the accepted Payment Request.
406    pub(super) payment_reference: PaymentReference,
407    /// Billing period. Required for recurring requests, `None` for one-time requests.
408    pub(super) billing_period: Option<BillingPeriod>,
409    /// Application whose endpoint was used for the payment.
410    pub(super) payment_app_id: crate::PaykitAppId,
411    /// Payment Endpoint Identifier used for the payment execution.
412    pub(super) payment_endpoint_identifier: PaymentEndpointIdentifier,
413    /// Optional Allowance used for this payment, scoped to the exact Encrypted Link.
414    pub(super) allowance_id: Option<AllowanceId>,
415    /// Event ID of the selected recurring conversion quote, when required.
416    pub(super) conversion_quote_id: Option<EventId>,
417    /// Method-specific proof object.
418    pub(super) proof: JsonMap<String, JsonValue>,
419}
420
421impl fmt::Debug for PaymentProof {
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        f.debug_struct("PaymentProof")
424            .field("version", &self.version)
425            .field("kind", &self.kind)
426            .field("event_id", &self.event_id)
427            .field("payment_request_id", &self.payment_request_id)
428            .field("payment_reference", &"<redacted>")
429            .field("billing_period", &self.billing_period)
430            .field("payment_app_id", &self.payment_app_id)
431            .field("allowance_id", &self.allowance_id)
432            .field(
433                "payment_endpoint_identifier",
434                &self.payment_endpoint_identifier,
435            )
436            .field(
437                "proof",
438                &format_args!("<redacted:{} fields>", self.proof.len()),
439            )
440            .finish()
441    }
442}
443
444impl PaymentProof {
445    /// Construct a Payment Proof using protocol version 1.
446    pub fn new(
447        event_id: EventId,
448        payment_request_id: PaymentRequestId,
449        payment_reference: PaymentReference,
450        billing_period: Option<BillingPeriod>,
451        payment_app_id: crate::PaykitAppId,
452        payment_endpoint_identifier: PaymentEndpointIdentifier,
453        proof: JsonMap<String, JsonValue>,
454    ) -> Self {
455        Self {
456            version: 1,
457            kind: PrivateMessageKind::PaymentProof,
458            event_id,
459            payment_request_id,
460            payment_reference,
461            billing_period,
462            payment_app_id,
463            payment_endpoint_identifier,
464            allowance_id: None,
465            conversion_quote_id: None,
466            proof,
467        }
468    }
469
470    /// Bind this proof to a previously issued recurring conversion quote.
471    pub fn with_conversion_quote_id(mut self, quote_id: EventId) -> Self {
472        self.conversion_quote_id = Some(quote_id);
473        self
474    }
475
476    /// Report the Allowance used for this payment execution.
477    ///
478    /// The caller must use its persisted payment association. This is reporting
479    /// only; it does not authorize payment, validate usage, or change accounting.
480    pub fn with_allowance_id(mut self, allowance_id: AllowanceId) -> Self {
481        self.allowance_id = Some(allowance_id);
482        self
483    }
484
485    /// Access the reported Allowance ID, when this payment used an Allowance.
486    pub fn allowance_id(&self) -> Option<&AllowanceId> {
487        self.allowance_id.as_ref()
488    }
489
490    pub(super) fn validate(&self) -> Result<()> {
491        crate::validation::validate_outgoing_version_kind(
492            self.version,
493            self.kind,
494            PrivateMessageKind::PaymentProof,
495            "Payment Proof",
496        )?;
497        if let Some(period) = &self.billing_period {
498            period.validate()?;
499        }
500        validate_method_specific_proof(&self.proof)
501    }
502
503    /// Validate this proof against the immutable terms of a specific Payment Request.
504    ///
505    /// Checks stateless correlation only: request ID, Payment Reference,
506    /// Billing Period presence/shape, accepted endpoint and conversion selection.
507    /// Use `validate_conversion_quote` to also check a selected quote. Caller
508    /// state owns lifecycle, role, dedupe, settlement and recurrence eligibility.
509    pub fn validate_for_request(&self, request: &PaymentRequest) -> Result<()> {
510        self.validate()?;
511        request.validate()?;
512        if self.payment_request_id != request.payment_request_id {
513            return Err(PaykitError::Validation(
514                "Payment Proof payment_request_id must match Payment Request".into(),
515            ));
516        }
517        if self.payment_reference != request.request.payment_reference {
518            return Err(PaykitError::Validation(
519                "Payment Proof payment_reference must match Payment Request".into(),
520            ));
521        }
522        if !request
523            .request
524            .accepted_payment_endpoint_identifiers
525            .contains(&self.payment_endpoint_identifier)
526            || request
527                .request
528                .payment_endpoints
529                .as_ref()
530                .is_some_and(|endpoints| !endpoints.contains_key(&self.payment_endpoint_identifier))
531        {
532            return Err(PaykitError::Validation(
533                "Payment Proof payment_endpoint_identifier is not accepted by Payment Request"
534                    .into(),
535            ));
536        }
537        if request
538            .request
539            .required_app_id
540            .as_ref()
541            .is_some_and(|app_id| app_id != &self.payment_app_id)
542        {
543            return Err(PaykitError::Validation(
544                "Payment Proof payment_app_id does not match the Payment Request".into(),
545            ));
546        }
547        self.validate_conversion(&request.request)?;
548        match (&request.request.recurrence, &self.billing_period) {
549            (None, Some(_)) => Err(PaykitError::Validation(
550                "Payment Proof billing_period must be null for one-time Payment Requests".into(),
551            )),
552            (Some(_), None) => Err(PaykitError::Validation(
553                "Payment Proof billing_period is required for recurring Payment Requests".into(),
554            )),
555            (_, Some(period)) => period.validate(),
556            (None, None) => Ok(()),
557        }
558    }
559}
560
561const ERC20_PAYMENT_PROOF_TYPE: &str = "erc20-transfer-eip712";
562const UINT256_MAX_DECIMAL: &str =
563    "115792089237316195423570985008687907853269984665640564039457584007913129639935";
564
565fn validate_method_specific_proof(proof: &JsonMap<String, JsonValue>) -> Result<()> {
566    if proof.get("type").and_then(JsonValue::as_str) != Some(ERC20_PAYMENT_PROOF_TYPE) {
567        return Ok(());
568    }
569    let index = proof
570        .get("receipt_log_index")
571        .and_then(JsonValue::as_str)
572        .ok_or_else(|| {
573            PaykitError::Validation(
574                "ERC-20 Payment Proof receipt_log_index must be a decimal string".into(),
575            )
576        })?;
577    let canonical = index == "0"
578        || (!index.is_empty()
579            && !index.starts_with('0')
580            && index.bytes().all(|byte| byte.is_ascii_digit()));
581    if !canonical
582        || index.len() > UINT256_MAX_DECIMAL.len()
583        || (index.len() == UINT256_MAX_DECIMAL.len() && index > UINT256_MAX_DECIMAL)
584    {
585        return Err(PaykitError::Validation(
586            "ERC-20 Payment Proof receipt_log_index must be a canonical uint256 decimal string"
587                .into(),
588        ));
589    }
590    Ok(())
591}
592
593/// One recognized Payment Request protocol Event Message in FIFO receive order.
594///
595/// This enum is intentionally exhaustive. Adding a variant must produce
596/// compile-time failures in serialization, lifecycle, and replay matches until
597/// the new Payment Request Event Message is classified explicitly. Do not add
598/// `#[non_exhaustive]` without a coordinated team decision.
599#[derive(Clone, PartialEq)]
600pub enum PaymentRequestEvent {
601    /// `paykit.payment_request` proposal event.
602    Request(PaymentRequest),
603    /// `paykit.payment_request_acceptance` event.
604    Acceptance(PaymentRequestAcceptance),
605    /// `paykit.payment_request_rejection` event.
606    Rejection(PaymentRequestRejection),
607    /// `paykit.payment_request_cancellation` event.
608    Cancellation(PaymentRequestCancellation),
609    /// Payee-issued recurring conversion quote.
610    ConversionQuote(PaymentConversionQuote),
611    /// `paykit.payment_proof` event.
612    Proof(PaymentProof),
613}
614
615impl fmt::Debug for PaymentRequestEvent {
616    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617        f.debug_struct("PaymentRequestEvent")
618            .field("kind", &self.kind())
619            .field("event_id", &self.event_id())
620            .field("payment_request_id", &self.payment_request_id())
621            .finish()
622    }
623}
624
625impl PaymentRequestEvent {
626    /// Return the Private Message Kind for this event.
627    pub fn kind(&self) -> PrivateMessageKind {
628        match self {
629            Self::Request(event) => event.kind,
630            Self::Acceptance(event) => event.kind,
631            Self::Rejection(event) => event.kind,
632            Self::Cancellation(event) => event.kind,
633            Self::Proof(event) => event.kind,
634            Self::ConversionQuote(event) => event.kind(),
635        }
636    }
637
638    /// Access the Event ID.
639    pub fn event_id(&self) -> &EventId {
640        match self {
641            Self::Request(event) => &event.event_id,
642            Self::Acceptance(event) => &event.event_id,
643            Self::Rejection(event) => &event.event_id,
644            Self::Cancellation(event) => &event.event_id,
645            Self::Proof(event) => &event.event_id,
646            Self::ConversionQuote(event) => event.event_id(),
647        }
648    }
649
650    /// Access the Payment Request ID shared by this lifecycle event.
651    pub fn payment_request_id(&self) -> &PaymentRequestId {
652        match self {
653            Self::Request(event) => &event.payment_request_id,
654            Self::Acceptance(event) => &event.payment_request_id,
655            Self::Rejection(event) => &event.payment_request_id,
656            Self::Cancellation(event) => &event.payment_request_id,
657            Self::Proof(event) => &event.payment_request_id,
658            Self::ConversionQuote(event) => event.payment_request_id(),
659        }
660    }
661}
662
663/// A recognized Payment Request protocol Event Message plus the raw JSON
664/// payload received from the Encrypted Link.
665#[derive(Clone, PartialEq)]
666pub struct PaymentRequestEventMessage {
667    /// Private Message Kind selected from the message header.
668    pub kind: PrivateMessageKind,
669    /// Application that created this event.
670    pub app_id: Option<crate::PaykitAppId>,
671    /// Parsed top-level Event ID when present and valid.
672    pub event_id: Option<EventId>,
673    /// Parsed top-level Payment Request ID when present and valid.
674    pub payment_request_id: Option<PaymentRequestId>,
675    /// Raw JSON plaintext as sent over the Encrypted Link.
676    pub raw_json: String,
677    /// Parsed event, or an error string explaining why this recognized message
678    /// failed structural validation.
679    pub event: std::result::Result<PaymentRequestEvent, String>,
680}
681
682impl fmt::Debug for PaymentRequestEventMessage {
683    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
684        let parsed_kind = self.event.as_ref().ok().map(PaymentRequestEvent::kind);
685        f.debug_struct("PaymentRequestEventMessage")
686            .field("kind", &self.kind)
687            .field("app_id", &self.app_id)
688            .field("event_id", &self.event_id)
689            .field("payment_request_id", &self.payment_request_id)
690            .field(
691                "raw_json",
692                &format_args!("<redacted:{} bytes>", self.raw_json.len()),
693            )
694            .field("parsed_kind", &parsed_kind)
695            .field("validation_error", &self.validation_error())
696            .finish()
697    }
698}
699
700impl PaymentRequestEventMessage {
701    /// Return the Private Message Kind for this event message.
702    pub fn kind(&self) -> PrivateMessageKind {
703        self.kind
704    }
705
706    /// Return the application that created this event, when valid.
707    pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
708        self.app_id.as_ref()
709    }
710
711    /// Whether the recognized event message parsed successfully.
712    pub fn is_valid(&self) -> bool {
713        self.event.is_ok()
714    }
715
716    /// Access the parsed event when structural validation succeeded.
717    pub fn parsed_event(&self) -> Option<&PaymentRequestEvent> {
718        self.event.as_ref().ok()
719    }
720
721    /// Access the validation error when structural validation failed.
722    pub fn validation_error(&self) -> Option<&str> {
723        self.event.as_ref().err().map(String::as_str)
724    }
725
726    /// Access the Event ID.
727    ///
728    /// Returns `None` when the recognized message is malformed and the Event ID
729    /// could not be parsed as a valid Event ID.
730    pub fn event_id(&self) -> Option<&EventId> {
731        self.event_id.as_ref()
732    }
733
734    /// Access the Payment Request ID shared by this lifecycle event.
735    ///
736    /// Returns `None` when the recognized message is malformed and the Payment
737    /// Request ID could not be parsed as a valid Payment Request ID.
738    pub fn payment_request_id(&self) -> Option<&PaymentRequestId> {
739        self.payment_request_id.as_ref()
740    }
741}
742
743impl Recurrence {
744    /// Check `every`, timestamp formats, and that a non-null `ends_at` is
745    /// after `starts_at`. `anchor` ordering is not constrained.
746    pub(super) fn validate(&self) -> Result<()> {
747        if self.every == 0 {
748            return Err(PaykitError::Validation(
749                "Recurrence every must be a positive integer".into(),
750            ));
751        }
752        let starts_at = parse_utc_timestamp(&self.starts_at, "Recurrence starts_at")?;
753        parse_utc_timestamp(&self.anchor, "Recurrence anchor")?;
754        if let Some(ends_at) = &self.ends_at {
755            let ends_at = parse_utc_timestamp(ends_at, "Recurrence ends_at")?;
756            if ends_at <= starts_at {
757                return Err(PaykitError::Validation(
758                    "Recurrence ends_at must be after starts_at".into(),
759                ));
760            }
761        }
762        Ok(())
763    }
764}
765
766impl BillingPeriod {
767    pub(crate) fn validate_with_label(&self, label: &str) -> Result<()> {
768        let starts_at = parse_utc_timestamp(&self.starts_at, &format!("{label} starts_at"))?;
769        let ends_at = parse_utc_timestamp(&self.ends_at, &format!("{label} ends_at"))?;
770        if ends_at <= starts_at {
771            return Err(PaykitError::Validation(format!(
772                "{label} ends_at must be after starts_at"
773            )));
774        }
775        Ok(())
776    }
777
778    pub(super) fn validate(&self) -> Result<()> {
779        self.validate_with_label("Billing Period")
780    }
781}
782
783impl PaymentRequestTerms {
784    pub(crate) fn validate(&self) -> Result<()> {
785        self.amount.validate_with_label("Payment Request amount")?;
786        if let Some(proposal_expires_at) = &self.proposal_expires_at {
787            parse_utc_timestamp(proposal_expires_at, "Payment Request proposal_expires_at")?;
788        }
789        if let Some(recurrence) = &self.recurrence {
790            recurrence.validate()?;
791        }
792        self.validate_conversion()?;
793        if self.accepted_payment_endpoint_identifiers.is_empty() {
794            return Err(PaykitError::Validation(
795                "accepted_payment_endpoint_identifiers must not be empty".into(),
796            ));
797        }
798        self.validate_payment_endpoints()?;
799        Ok(())
800    }
801
802    fn validate_payment_endpoints(&self) -> Result<()> {
803        let Some(endpoints) = &self.payment_endpoints else {
804            return Ok(());
805        };
806        if endpoints.is_empty() {
807            return Err(PaykitError::Validation(
808                "payment_endpoints must not be empty".into(),
809            ));
810        }
811        if self.required_app_id.is_none() {
812            return Err(PaykitError::Validation(
813                "payment_endpoints requires required_app_id".into(),
814            ));
815        }
816        for (identifier, payload) in endpoints {
817            if !self
818                .accepted_payment_endpoint_identifiers
819                .contains(identifier)
820            {
821                return Err(PaykitError::Validation(
822                    "payment_endpoints keys must be accepted Payment Endpoint Identifiers".into(),
823                ));
824            }
825            if payload.as_str().is_empty() {
826                return Err(PaykitError::Validation(
827                    "payment_endpoints payloads must not be empty".into(),
828                ));
829            }
830        }
831        Ok(())
832    }
833}
834
835impl Recurrence {
836    /// Access the every.
837    pub fn every(&self) -> u32 {
838        self.every
839    }
840    /// Access the unit.
841    pub fn unit(&self) -> RecurrenceUnit {
842        self.unit
843    }
844    /// Access the preserved UTC start timestamp.
845    pub fn starts_at(&self) -> &str {
846        &self.starts_at
847    }
848    /// Access the anchor.
849    pub fn anchor(&self) -> &str {
850        &self.anchor
851    }
852    /// Access the end timestamp.
853    pub fn ends_at(&self) -> &Option<String> {
854        &self.ends_at
855    }
856}
857
858impl PaymentRequestTerms {
859    /// Access the amount.
860    pub fn amount(&self) -> &PaymentAmount {
861        &self.amount
862    }
863    /// Access the Payment Reference.
864    pub fn payment_reference(&self) -> &PaymentReference {
865        &self.payment_reference
866    }
867    /// Access the optional proposal expiry timestamp.
868    pub fn proposal_expires_at(&self) -> &Option<String> {
869        &self.proposal_expires_at
870    }
871    /// Access the recurrence.
872    pub fn recurrence(&self) -> &Option<Recurrence> {
873        &self.recurrence
874    }
875    /// Access the accepted Payment Endpoint Identifiers.
876    pub fn accepted_payment_endpoint_identifiers(&self) -> &[PaymentEndpointIdentifier] {
877        &self.accepted_payment_endpoint_identifiers
878    }
879    /// Access immutable request-bound Payment Endpoints, when present.
880    ///
881    /// These destinations belong to `required_app_id`. A payer must not fall
882    /// back to endpoints outside this map. Absence leaves endpoint discovery unchanged.
883    ///
884    /// ```compile_fail,E0596
885    /// fn cannot_mutate(value: paykit_lib::PaymentRequestTerms) {
886    ///     value.payment_endpoints().unwrap().clear();
887    /// }
888    /// ```
889    pub fn payment_endpoints(
890        &self,
891    ) -> Option<&HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>> {
892        self.payment_endpoints.as_ref()
893    }
894    /// Access the optional payee App constraint.
895    pub fn required_app_id(&self) -> Option<&crate::PaykitAppId> {
896        self.required_app_id.as_ref()
897    }
898    /// Access the optional conversion policy.
899    pub fn conversion(&self) -> Option<&PaymentConversion> {
900        self.conversion.as_ref()
901    }
902    /// Access the optional payment deadline.
903    pub fn payment_deadline(&self) -> Option<&PaymentDeadline> {
904        self.payment_deadline.as_ref()
905    }
906    /// Access the metadata.
907    pub fn metadata(&self) -> &JsonMap<String, JsonValue> {
908        &self.metadata
909    }
910}
911
912impl BillingPeriod {
913    /// Access the preserved UTC start timestamp.
914    pub fn starts_at(&self) -> &str {
915        &self.starts_at
916    }
917    /// Access the end timestamp.
918    pub fn ends_at(&self) -> &str {
919        &self.ends_at
920    }
921}
922
923impl PaymentRequest {
924    /// Access the version.
925    pub fn version(&self) -> u8 {
926        self.version
927    }
928    /// Access the kind.
929    pub fn kind(&self) -> PrivateMessageKind {
930        self.kind
931    }
932    /// Access the Event ID.
933    pub fn event_id(&self) -> &EventId {
934        &self.event_id
935    }
936    /// Access the Payment Request ID.
937    pub fn payment_request_id(&self) -> &PaymentRequestId {
938        &self.payment_request_id
939    }
940    /// Access the immutable request terms.
941    pub fn request(&self) -> &PaymentRequestTerms {
942        &self.request
943    }
944}
945
946impl PaymentRequestAcceptance {
947    /// Access the version.
948    pub fn version(&self) -> u8 {
949        self.version
950    }
951    /// Access the kind.
952    pub fn kind(&self) -> PrivateMessageKind {
953        self.kind
954    }
955    /// Access the Event ID.
956    pub fn event_id(&self) -> &EventId {
957        &self.event_id
958    }
959    /// Access the Payment Request ID.
960    pub fn payment_request_id(&self) -> &PaymentRequestId {
961        &self.payment_request_id
962    }
963}
964
965impl PaymentRequestRejection {
966    /// Access the version.
967    pub fn version(&self) -> u8 {
968        self.version
969    }
970    /// Access the kind.
971    pub fn kind(&self) -> PrivateMessageKind {
972        self.kind
973    }
974    /// Access the Event ID.
975    pub fn event_id(&self) -> &EventId {
976        &self.event_id
977    }
978    /// Access the Payment Request ID.
979    pub fn payment_request_id(&self) -> &PaymentRequestId {
980        &self.payment_request_id
981    }
982    /// Access the reason.
983    pub fn reason(&self) -> &Option<String> {
984        &self.reason
985    }
986}
987
988impl PaymentRequestCancellation {
989    /// Access the version.
990    pub fn version(&self) -> u8 {
991        self.version
992    }
993    /// Access the kind.
994    pub fn kind(&self) -> PrivateMessageKind {
995        self.kind
996    }
997    /// Access the Event ID.
998    pub fn event_id(&self) -> &EventId {
999        &self.event_id
1000    }
1001    /// Access the Payment Request ID.
1002    pub fn payment_request_id(&self) -> &PaymentRequestId {
1003        &self.payment_request_id
1004    }
1005    /// Access the reason.
1006    pub fn reason(&self) -> &Option<String> {
1007        &self.reason
1008    }
1009}
1010
1011impl PaymentProof {
1012    /// Access the version.
1013    pub fn version(&self) -> u8 {
1014        self.version
1015    }
1016    /// Access the kind.
1017    pub fn kind(&self) -> PrivateMessageKind {
1018        self.kind
1019    }
1020    /// Access the Event ID.
1021    pub fn event_id(&self) -> &EventId {
1022        &self.event_id
1023    }
1024    /// Access the Payment Request ID.
1025    pub fn payment_request_id(&self) -> &PaymentRequestId {
1026        &self.payment_request_id
1027    }
1028    /// Access the Payment Reference.
1029    pub fn payment_reference(&self) -> &PaymentReference {
1030        &self.payment_reference
1031    }
1032    /// Access the optional Billing Period.
1033    pub fn billing_period(&self) -> &Option<BillingPeriod> {
1034        &self.billing_period
1035    }
1036    /// Access the payee App whose endpoint was used for payment.
1037    pub fn payment_app_id(&self) -> &crate::PaykitAppId {
1038        &self.payment_app_id
1039    }
1040    /// Access the Payment Endpoint Identifier.
1041    pub fn payment_endpoint_identifier(&self) -> &PaymentEndpointIdentifier {
1042        &self.payment_endpoint_identifier
1043    }
1044    /// Access the selected recurring conversion quote, when required.
1045    pub fn conversion_quote_id(&self) -> Option<&EventId> {
1046        self.conversion_quote_id.as_ref()
1047    }
1048    /// Access the proof.
1049    pub fn proof(&self) -> &JsonMap<String, JsonValue> {
1050        &self.proof
1051    }
1052}
1053
1054/// Unvalidated input for a recurring Payment Request schedule.
1055///
1056/// Convert with [`Recurrence::try_from`] to validate the complete schedule.
1057#[derive(Clone, Debug)]
1058pub struct RecurrenceConfig {
1059    /// Positive interval count.
1060    pub every: u32,
1061    /// Calendar or fixed-duration recurrence unit.
1062    pub unit: RecurrenceUnit,
1063    /// First UTC instant, using the `Z` suffix.
1064    pub starts_at: String,
1065    /// UTC schedule anchor; it need not precede the start.
1066    pub anchor: String,
1067    /// Optional exclusive end, strictly after the start.
1068    pub ends_at: Option<String>,
1069}
1070
1071impl TryFrom<RecurrenceConfig> for Recurrence {
1072    type Error = PaykitError;
1073    fn try_from(value: RecurrenceConfig) -> Result<Self> {
1074        let recurrence = Self {
1075            every: value.every,
1076            unit: value.unit,
1077            starts_at: value.starts_at,
1078            anchor: value.anchor,
1079            ends_at: value.ends_at,
1080        };
1081        recurrence.validate()?;
1082        Ok(recurrence)
1083    }
1084}
1085
1086impl BillingPeriod {
1087    /// Construct an ordered Billing Period with RFC3339 UTC `Z` timestamps.
1088    ///
1089    /// This validates the interval shape, not membership in a Recurrence.
1090    pub fn new(starts_at: impl Into<String>, ends_at: impl Into<String>) -> Result<Self> {
1091        Self::new_with_label(starts_at.into(), ends_at.into(), "Billing Period")
1092    }
1093
1094    pub(crate) fn new_with_label(starts_at: String, ends_at: String, label: &str) -> Result<Self> {
1095        let period = Self { starts_at, ends_at };
1096        period.validate_with_label(label)?;
1097        Ok(period)
1098    }
1099}
1100
1101/// Unvalidated builder for immutable Payment Request terms.
1102#[derive(Clone)]
1103pub struct PaymentRequestTermsBuilder(PaymentRequestTerms);
1104
1105impl PaymentRequestTerms {
1106    /// Start constructing terms with their required amount, reference, and endpoints.
1107    ///
1108    /// Call [`PaymentRequestTermsBuilder::build`] to validate the complete terms.
1109    pub fn builder(
1110        amount: PaymentAmount,
1111        payment_reference: PaymentReference,
1112        accepted_payment_endpoint_identifiers: Vec<PaymentEndpointIdentifier>,
1113    ) -> PaymentRequestTermsBuilder {
1114        PaymentRequestTermsBuilder(Self {
1115            amount,
1116            payment_reference,
1117            accepted_payment_endpoint_identifiers,
1118            payment_endpoints: None,
1119            required_app_id: None,
1120            proposal_expires_at: None,
1121            recurrence: None,
1122            conversion: None,
1123            payment_deadline: None,
1124            metadata: JsonMap::new(),
1125        })
1126    }
1127}
1128
1129impl PaymentRequestTermsBuilder {
1130    /// Bind payment to immutable Payment Endpoints owned by `required_app_id`.
1131    ///
1132    /// A supplied map must be nonempty, contain nonempty payloads, and use only
1133    /// accepted Payment Endpoint Identifiers. `required_app_id` must be set.
1134    /// `None` preserves endpoint discovery; `Some` forbids fallback outside the map.
1135    pub fn payment_endpoints(
1136        mut self,
1137        value: Option<HashMap<PaymentEndpointIdentifier, PaymentEndpointPayload>>,
1138    ) -> Self {
1139        self.0.payment_endpoints = value;
1140        self
1141    }
1142    /// Constrain payment to an endpoint owned by the specified payee App.
1143    pub fn required_app_id(mut self, value: Option<crate::PaykitAppId>) -> Self {
1144        self.0.required_app_id = value;
1145        self
1146    }
1147    /// Set the optional proposal expiry; past timestamps remain valid history.
1148    pub fn proposal_expires_at(mut self, value: Option<String>) -> Self {
1149        self.0.proposal_expires_at = value;
1150        self
1151    }
1152    /// Set a validated recurrence, or `None` for a one-time request.
1153    pub fn recurrence(mut self, value: Option<Recurrence>) -> Self {
1154        self.0.recurrence = value;
1155        self
1156    }
1157    /// Set the optional conversion policy.
1158    pub fn conversion(mut self, value: Option<PaymentConversion>) -> Self {
1159        self.0.conversion = value;
1160        self
1161    }
1162    /// Set the optional payment deadline.
1163    pub fn payment_deadline(mut self, value: Option<PaymentDeadline>) -> Self {
1164        self.0.payment_deadline = value;
1165        self
1166    }
1167    /// Set application-specific metadata without interpreting its contents.
1168    pub fn metadata(mut self, value: JsonMap<String, JsonValue>) -> Self {
1169        self.0.metadata = value;
1170        self
1171    }
1172    /// Validate the complete terms, including the non-empty endpoint list.
1173    pub fn build(self) -> Result<PaymentRequestTerms> {
1174        self.0.validate()?;
1175        Ok(self.0)
1176    }
1177}
1178
1179#[cfg(test)]
1180mod tests;