paykit_lib/payment_request/
conversion.rs1use 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#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct ConversionRate {
18 pub asset: String,
20 pub value: String,
22}
23
24impl ConversionRate {
25 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
52pub enum PaymentConversion {
53 Fixed {
55 rates: Vec<ConversionRate>,
57 },
58 PerPeriod {},
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
65pub enum PaymentDeadline {
66 At {
68 timestamp: String,
70 },
71 PeriodStart {
73 seconds: u64,
75 },
76}
77
78impl PaymentDeadline {
79 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#[derive(Clone, Debug, PartialEq)]
134pub struct PaymentConversionQuote {
135 version: u8,
137 kind: PrivateMessageKind,
139 event_id: EventId,
141 payment_request_id: PaymentRequestId,
143 billing_period: BillingPeriod,
145 rates: Vec<ConversionRate>,
147 valid_from: String,
149 expires_at: String,
151}
152
153impl PaymentConversionQuote {
154 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 pub fn version(&self) -> u8 {
179 self.version
180 }
181
182 pub fn kind(&self) -> PrivateMessageKind {
184 self.kind
185 }
186
187 pub fn event_id(&self) -> &EventId {
189 &self.event_id
190 }
191
192 pub fn payment_request_id(&self) -> &PaymentRequestId {
194 &self.payment_request_id
195 }
196
197 pub fn billing_period(&self) -> &BillingPeriod {
199 &self.billing_period
200 }
201
202 pub fn rates(&self) -> &[ConversionRate] {
204 &self.rates
205 }
206
207 pub fn valid_from(&self) -> &str {
209 &self.valid_from
210 }
211
212 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 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 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 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}