Skip to main content

paykit_lib/payment_request/
conversion.rs

1use std::{collections::HashSet, fmt};
2
3use chrono::{DateTime, Datelike, FixedOffset, SecondsFormat, TimeDelta};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    validation::{parse_utc_timestamp, validate_decimal_text, validate_outgoing_version_kind},
8    EventId, PaykitError, PaymentEndpointIdentifier, PrivateMessageKind, Result,
9};
10
11use super::{BillingPeriod, PaymentProof, PaymentRequest, PaymentRequestId, PaymentRequestTerms};
12
13/// Units of the payment asset owed per one unit of the requested asset.
14/// Values are positive decimal strings; implementations must not use floating point.
15#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct ConversionRate {
18    /// Payment asset or asset-rail selector, for example `usdt` or `usdt-polygon`.
19    pub asset: String,
20    /// Payment asset units per requested asset unit.
21    pub value: String,
22}
23
24impl ConversionRate {
25    /// Select an explicit rate, preferring an exact asset-rail match over the asset default.
26    ///
27    /// Validates the nonempty rate list and endpoint grammar. This does not check
28    /// request acceptance or quote validity. `None` means no explicit rate: only
29    /// the requested asset may then use implicit 1:1 pricing.
30    pub fn for_endpoint<'a>(
31        rates: &'a [Self],
32        endpoint: &PaymentEndpointIdentifier,
33    ) -> Result<Option<&'a Self>> {
34        validate_rates(rates)?;
35        let (asset, asset_rail) = endpoint_rate_selectors(endpoint)?;
36        Ok(rates
37            .iter()
38            .find(|rate| rate.asset == asset_rail)
39            .or_else(|| rates.iter().find(|rate| rate.asset == asset)))
40    }
41}
42
43impl fmt::Debug for ConversionRate {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str("ConversionRate(<redacted>)")
46    }
47}
48
49/// Conversion policy agreed in immutable Payment Request terms.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
52pub enum PaymentConversion {
53    /// Fixed rates apply to the request and every recurring installment.
54    Fixed {
55        /// An omitted payment asset is unavailable for conversion.
56        rates: Vec<ConversionRate>,
57    },
58    /// Recurring cross-asset payments require a payee-issued quote for their Billing Period.
59    PerPeriod {},
60}
61
62/// Payment deadline, independent of the deadline for accepting a proposal.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
65pub enum PaymentDeadline {
66    /// Absolute deadline for a one-time payment.
67    At {
68        /// RFC3339 UTC timestamp with a Z suffix.
69        timestamp: String,
70    },
71    /// Deadline measured from the start of each recurring Billing Period.
72    PeriodStart {
73        /// Nonnegative elapsed seconds after the period starts.
74        seconds: u64,
75    },
76}
77
78impl PaymentDeadline {
79    /// Resolve a deadline. Callers verify actual payment time, not proof arrival time.
80    pub fn at(&self, period: Option<&BillingPeriod>) -> Result<String> {
81        if let Some(period) = period {
82            period.validate()?;
83        }
84        let deadline = self.resolve(period.map(|period| period.starts_at.as_str()))?;
85        Ok(deadline.to_rfc3339_opts(SecondsFormat::AutoSi, true))
86    }
87
88    fn resolve(&self, period_start: Option<&str>) -> Result<DateTime<FixedOffset>> {
89        match (self, period_start) {
90            (Self::At { timestamp }, None) => conversion_timestamp(timestamp, "payment deadline"),
91            (Self::PeriodStart { seconds }, Some(start)) => deadline_after(start, *seconds),
92            _ => Err(PaykitError::Validation(
93                "payment deadline must match request recurrence".into(),
94            )),
95        }
96    }
97}
98
99fn conversion_timestamp(value: &str, field: &str) -> Result<DateTime<FixedOffset>> {
100    let parsed = parse_utc_timestamp(value, field)?;
101    if value.as_bytes().get(10) != Some(&b'T')
102        || !(0..=9999).contains(&parsed.year())
103        || parsed.timestamp_subsec_nanos() >= 1_000_000_000
104    {
105        return Err(PaykitError::Validation(format!(
106            "{field} must use a four-digit year, T separator and no leap second"
107        )));
108    }
109    Ok(parsed)
110}
111
112fn deadline_after(starts_at: &str, seconds: u64) -> Result<DateTime<FixedOffset>> {
113    let offset = i64::try_from(seconds)
114        .ok()
115        .and_then(TimeDelta::try_seconds)
116        .ok_or_else(|| PaykitError::Validation("payment deadline offset is too large".into()))?;
117    conversion_timestamp(starts_at, "period starts_at")?
118        .checked_add_signed(offset)
119        .filter(|deadline| (0..=9999).contains(&deadline.year()))
120        .ok_or_else(|| PaykitError::Validation("payment deadline is out of range".into()))
121}
122
123/// Payee-issued rates for one recurring Billing Period.
124/// The Event ID is the quote identifier. New quotes never replace earlier quotes.
125///
126/// Fields cannot be mutated after validated construction.
127///
128/// ```compile_fail,E0616
129/// fn cannot_mutate(mut quote: paykit_lib::PaymentConversionQuote) {
130///     quote.expires_at = "invalid".into();
131/// }
132/// ```
133#[derive(Clone, Debug, PartialEq)]
134pub struct PaymentConversionQuote {
135    /// Protocol message version.
136    version: u8,
137    /// Private Message Kind.
138    kind: PrivateMessageKind,
139    /// Immutable quote identity, reused when referring to this quote from a proof.
140    event_id: EventId,
141    /// Request whose denomination and accepted endpoints apply.
142    payment_request_id: PaymentRequestId,
143    /// Period these rates apply to; eligibility is checked by the wallet's recurrence policy.
144    billing_period: BillingPeriod,
145    /// Payment asset units per one requested asset unit.
146    rates: Vec<ConversionRate>,
147    /// Inclusive start of the payment validity interval, in RFC3339 UTC.
148    valid_from: String,
149    /// Inclusive payment deadline for this quote, in RFC3339 UTC.
150    expires_at: String,
151}
152
153impl PaymentConversionQuote {
154    /// Construct and validate a recurring quote.
155    pub fn new(
156        event_id: EventId,
157        payment_request_id: PaymentRequestId,
158        billing_period: BillingPeriod,
159        rates: Vec<ConversionRate>,
160        valid_from: String,
161        expires_at: String,
162    ) -> Result<Self> {
163        let quote = Self {
164            version: 1,
165            kind: PrivateMessageKind::PaymentConversionQuote,
166            event_id,
167            payment_request_id,
168            billing_period,
169            rates,
170            valid_from,
171            expires_at,
172        };
173        quote.validate()?;
174        Ok(quote)
175    }
176
177    /// Return the protocol message version.
178    pub fn version(&self) -> u8 {
179        self.version
180    }
181
182    /// Return the Private Message Kind.
183    pub fn kind(&self) -> PrivateMessageKind {
184        self.kind
185    }
186
187    /// Access the immutable quote Event ID.
188    pub fn event_id(&self) -> &EventId {
189        &self.event_id
190    }
191
192    /// Access the associated Payment Request ID.
193    pub fn payment_request_id(&self) -> &PaymentRequestId {
194        &self.payment_request_id
195    }
196
197    /// Access the quoted Billing Period.
198    pub fn billing_period(&self) -> &BillingPeriod {
199        &self.billing_period
200    }
201
202    /// Access the immutable conversion rates.
203    pub fn rates(&self) -> &[ConversionRate] {
204        &self.rates
205    }
206
207    /// Access the inclusive start of the quote validity interval.
208    pub fn valid_from(&self) -> &str {
209        &self.valid_from
210    }
211
212    /// Access the inclusive end of the quote validity interval.
213    pub fn expires_at(&self) -> &str {
214        &self.expires_at
215    }
216
217    pub(super) fn validate(&self) -> Result<()> {
218        validate_outgoing_version_kind(
219            self.version,
220            self.kind,
221            PrivateMessageKind::PaymentConversionQuote,
222            "Payment Conversion Quote",
223        )?;
224        self.billing_period.validate()?;
225        validate_rates(&self.rates)?;
226        conversion_timestamp(&self.billing_period.starts_at, "period starts_at")?;
227        conversion_timestamp(&self.billing_period.ends_at, "period ends_at")?;
228        let start = conversion_timestamp(&self.valid_from, "quote valid_from")?;
229        let end = conversion_timestamp(&self.expires_at, "quote expires_at")?;
230        if start > end {
231            return Err(PaykitError::Validation(
232                "quote validity interval is reversed".into(),
233            ));
234        }
235        Ok(())
236    }
237
238    /// Check immutable request association. Role, lifecycle and calendar eligibility belong to the caller.
239    pub fn validate_for_request(&self, request: &PaymentRequest) -> Result<()> {
240        self.validate()?;
241        request.validate()?;
242        if self.payment_request_id != request.payment_request_id
243            || request.request.conversion != Some(PaymentConversion::PerPeriod {})
244        {
245            return Err(PaykitError::Validation(
246                "quote requires its recurring per-period Payment Request".into(),
247            ));
248        }
249        validate_rate_assets(&self.rates, &request.request)?;
250        // Same-asset per-period payments use implicit 1:1 pricing without a quote,
251        // so an optional quote cannot change that price.
252        if self
253            .rates
254            .iter()
255            .any(|rate| rate.asset.split('-').next() == Some(request.request.amount.asset()))
256        {
257            return Err(PaykitError::Validation(
258                "per-period quotes only price cross-asset payments".into(),
259            ));
260        }
261        Ok(())
262    }
263}
264
265fn validate_rates(rates: &[ConversionRate]) -> Result<()> {
266    if rates.is_empty() {
267        return Err(PaykitError::Validation(
268            "conversion rates must not be empty".into(),
269        ));
270    }
271    let mut selectors = HashSet::new();
272    for rate in rates {
273        validate_decimal_text(&rate.value, "conversion rate.value")?;
274        let mut segments = rate.asset.split('-');
275        if !segments.next().is_some_and(valid_asset_segment)
276            || !segments.next().is_none_or(valid_asset_segment)
277            || segments.next().is_some()
278        {
279            return Err(PaykitError::Validation(
280                "conversion rate asset must be asset or asset-rail with lowercase alphanumeric segments".into(),
281            ));
282        }
283        if !rate.value.bytes().any(|b| matches!(b, b'1'..=b'9')) || !selectors.insert(&rate.asset) {
284            return Err(PaykitError::Validation(
285                "conversion rates must be positive and selectors unique".into(),
286            ));
287        }
288    }
289    Ok(())
290}
291
292fn validate_rate_assets(rates: &[ConversionRate], terms: &PaymentRequestTerms) -> Result<()> {
293    for rate in rates {
294        if !terms
295            .accepted_payment_endpoint_identifiers
296            .iter()
297            .any(|id| {
298                endpoint_rate_selectors(id).is_ok_and(|(asset, asset_rail)| {
299                    rate.asset == asset || rate.asset == asset_rail
300                })
301            })
302        {
303            return Err(PaykitError::Validation(
304                "conversion rate must name an accepted asset or asset-rail".into(),
305            ));
306        }
307    }
308    Ok(())
309}
310
311fn endpoint_rate_selectors(identifier: &PaymentEndpointIdentifier) -> Result<(&str, &str)> {
312    let (asset_rail, format) = identifier.as_str().rsplit_once('-').unwrap_or(("", ""));
313    let (asset, rail) = asset_rail.split_once('-').unwrap_or(("", ""));
314    if valid_asset_segment(asset) && valid_asset_segment(rail) && valid_asset_segment(format) {
315        Ok((asset, asset_rail))
316    } else {
317        Err(PaykitError::Validation(
318            "conversion requires asset-rail-format identifiers with lowercase alphanumeric segments".into(),
319        ))
320    }
321}
322
323fn valid_asset_segment(value: &str) -> bool {
324    !value.is_empty()
325        && value
326            .bytes()
327            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
328}
329
330impl PaymentRequestTerms {
331    pub(super) fn validate_conversion(&self) -> Result<()> {
332        if let Some(conversion) = &self.conversion {
333            if !valid_asset_segment(self.amount.asset()) {
334                return Err(PaykitError::Validation(
335                    "conversion requires a lowercase alphanumeric request asset".into(),
336                ));
337            }
338            for id in &self.accepted_payment_endpoint_identifiers {
339                endpoint_rate_selectors(id)?;
340            }
341            match conversion {
342                PaymentConversion::Fixed { rates } => {
343                    validate_rates(rates)?;
344                    validate_rate_assets(rates, self)?;
345                }
346                PaymentConversion::PerPeriod {} if self.recurrence.is_none() => {
347                    return Err(PaykitError::Validation(
348                        "per-period conversion requires recurrence".into(),
349                    ))
350                }
351                PaymentConversion::PerPeriod {} => {}
352            }
353        }
354        if let Some(deadline) = &self.payment_deadline {
355            deadline.resolve(
356                self.recurrence
357                    .as_ref()
358                    .map(|recurrence| recurrence.starts_at.as_str()),
359            )?;
360        }
361        Ok(())
362    }
363}
364
365impl PaymentProof {
366    /// Validate quote selection without rejecting a delayed proof merely because the quote has expired.
367    /// Callers separately verify the paid amount and actual payment time against the selected terms.
368    pub fn validate_conversion_quote(
369        &self,
370        request: &PaymentRequest,
371        quote: Option<&PaymentConversionQuote>,
372    ) -> Result<()> {
373        self.validate_for_request(request)?;
374        match (&self.conversion_quote_id, quote) {
375            (Some(id), Some(quote)) if id == quote.event_id() => {
376                quote.validate_for_request(request)?;
377                let same_period = self
378                    .billing_period
379                    .as_ref()
380                    .map(|period| -> Result<bool> {
381                        Ok(conversion_timestamp(&period.starts_at, "period starts_at")?
382                            == conversion_timestamp(
383                                quote.billing_period().starts_at(),
384                                "period starts_at",
385                            )?
386                            && conversion_timestamp(&period.ends_at, "period ends_at")?
387                                == conversion_timestamp(
388                                    quote.billing_period().ends_at(),
389                                    "period ends_at",
390                                )?)
391                    })
392                    .transpose()?
393                    .unwrap_or(false);
394                if !same_period {
395                    return Err(PaykitError::Validation(
396                        "proof and quote Billing Period must match".into(),
397                    ));
398                }
399                self.validate_rate_coverage(&request.request, quote.rates())
400            }
401            (None, None) => Ok(()),
402            _ => Err(PaykitError::Validation(
403                "proof must reference a known matching conversion quote".into(),
404            )),
405        }
406    }
407
408    pub(super) fn validate_conversion(&self, terms: &PaymentRequestTerms) -> Result<()> {
409        match &terms.conversion {
410            Some(PaymentConversion::Fixed { rates }) => {
411                if self.conversion_quote_id.is_some() {
412                    return Err(PaykitError::Validation(
413                        "fixed conversion does not use quotes".into(),
414                    ));
415                }
416                self.validate_rate_coverage(terms, rates)
417            }
418            Some(PaymentConversion::PerPeriod {}) => {
419                if endpoint_rate_selectors(&self.payment_endpoint_identifier)?.0
420                    != terms.amount.asset()
421                    && self.conversion_quote_id.is_none()
422                {
423                    return Err(PaykitError::Validation(
424                        "cross-asset proof requires a conversion quote".into(),
425                    ));
426                }
427                Ok(())
428            }
429            None if self.conversion_quote_id.is_some() => Err(PaykitError::Validation(
430                "request does not accept conversion quotes".into(),
431            )),
432            None => Ok(()),
433        }
434    }
435
436    fn validate_rate_coverage(
437        &self,
438        terms: &PaymentRequestTerms,
439        rates: &[ConversionRate],
440    ) -> Result<()> {
441        let asset = endpoint_rate_selectors(&self.payment_endpoint_identifier)?.0;
442        if ConversionRate::for_endpoint(rates, &self.payment_endpoint_identifier)?.is_some()
443            || asset == terms.amount.asset()
444        {
445            Ok(())
446        } else {
447            Err(PaykitError::Validation(
448                "payment asset has no agreed conversion rate".into(),
449            ))
450        }
451    }
452}