Skip to main content

paykit_lib/allowance/
evaluation.rs

1//! Pure eligibility math over caller-provided complete accounting evidence.
2
3use super::{
4    allowance_period_window,
5    amounts::{add_decimals, compare_decimals},
6    AllowanceEvaluation, AllowanceEvaluationBlock, AllowanceEvaluationInput, AllowanceTerms,
7};
8use crate::{validation::parse_utc_timestamp, PaymentEndpointIdentifier, PaymentRequestTerms};
9use chrono::{DateTime, Utc};
10
11/// Match immutable request terms against an Allowance without lifecycle or time checks.
12///
13/// Exact case-sensitive assets, inclusive decimal amount range, and endpoint
14/// intersection apply. This never selects an Allowance or resolves endpoint data.
15pub fn match_allowance_request(
16    terms: &AllowanceTerms,
17    request: &PaymentRequestTerms,
18) -> std::result::Result<Vec<PaymentEndpointIdentifier>, AllowanceEvaluationBlock> {
19    request
20        .validate()
21        .map_err(|_| AllowanceEvaluationBlock::InvalidRequest)?;
22    if request.amount().asset() != terms.asset() {
23        return Err(AllowanceEvaluationBlock::AssetMismatch);
24    }
25    if let Some(range) = terms.per_payment_amount() {
26        if compare_decimals(request.amount().value(), range.minimum()).is_lt()
27            || compare_decimals(request.amount().value(), range.maximum()).is_gt()
28        {
29            return Err(AllowanceEvaluationBlock::AmountOutsideRange);
30        }
31    }
32    let mut endpoints = Vec::new();
33    for endpoint in request.accepted_payment_endpoint_identifiers() {
34        // Allowances account in the requested amount; explicitly priced
35        // endpoints require manual approval instead of consuming that budget.
36        if let Some(crate::PaymentConversion::Fixed { rates }) = request.conversion() {
37            if crate::ConversionRate::for_endpoint(rates, endpoint)
38                .map_err(|_| AllowanceEvaluationBlock::InvalidRequest)?
39                .is_some()
40            {
41                continue;
42            }
43        }
44        if terms
45            .allowed_payment_endpoint_identifiers()
46            .is_none_or(|allowed| allowed.contains(endpoint))
47            && !endpoints.contains(endpoint)
48        {
49            endpoints.push(endpoint.clone());
50        }
51    }
52    if endpoints.is_empty() {
53        return Err(AllowanceEvaluationBlock::NoEligibleEndpoint);
54    }
55    Ok(endpoints)
56}
57
58/// Check static matching, active time, watermark, and every amount/count limit.
59///
60/// The candidate consumes one count and its requested amount at `trusted_time`.
61/// The SDK must atomically recheck lifecycle/scope, payment dedupe, this result,
62/// and capacity before admitting payment, then durably advance the watermark.
63/// No clock is read, state changed, endpoint resolved, or payment authorized here.
64pub fn evaluate_allowance(
65    input: &AllowanceEvaluationInput<'_>,
66) -> std::result::Result<AllowanceEvaluation, AllowanceEvaluationBlock> {
67    let endpoints = match_allowance_request(input.terms, input.request)?;
68    check_allowance_time(input.terms, input.trusted_time, input.watermark)?;
69    validate_usage(input)?;
70    check_lifetime_limit(input)?;
71    check_period_limits(input)?;
72    Ok(AllowanceEvaluation {
73        eligible_payment_endpoint_identifiers: endpoints,
74    })
75}
76
77/// Check the active window and durable trusted-time watermark without consuming capacity.
78///
79/// This supports candidate selection and recurring Acceptance, which reserve no
80/// usage. The caller remains responsible for lifecycle, history completeness,
81/// consent, and persisting any watermark advancement under its storage rules.
82pub fn check_allowance_time(
83    terms: &AllowanceTerms,
84    trusted_time: DateTime<Utc>,
85    watermark: DateTime<Utc>,
86) -> std::result::Result<(), AllowanceEvaluationBlock> {
87    if trusted_time < watermark {
88        return Err(AllowanceEvaluationBlock::ClockRollback);
89    }
90    if trusted_time.timestamp_subsec_nanos() >= 1_000_000_000
91        || watermark.timestamp_subsec_nanos() >= 1_000_000_000
92    {
93        return Err(AllowanceEvaluationBlock::ArithmeticOverflow);
94    }
95    if parse_time_bound(terms.active_from())?.is_some_and(|start| trusted_time < start) {
96        return Err(AllowanceEvaluationBlock::NotActive);
97    }
98    if parse_time_bound(terms.expires_at())?.is_some_and(|end| trusted_time >= end) {
99        return Err(AllowanceEvaluationBlock::Expired);
100    }
101    Ok(())
102}
103
104fn parse_time_bound(
105    bound: Option<&str>,
106) -> std::result::Result<Option<chrono::DateTime<chrono::Utc>>, AllowanceEvaluationBlock> {
107    bound
108        .map(|value| {
109            let time = parse_utc_timestamp(value, "Allowance time bound")
110                .map_err(|_| AllowanceEvaluationBlock::ArithmeticOverflow)?;
111            if time.timestamp_subsec_nanos() >= 1_000_000_000 {
112                return Err(AllowanceEvaluationBlock::ArithmeticOverflow);
113            }
114            Ok(time.with_timezone(&chrono::Utc))
115        })
116        .transpose()
117}
118
119fn validate_usage(
120    input: &AllowanceEvaluationInput<'_>,
121) -> std::result::Result<(), AllowanceEvaluationBlock> {
122    for entry in input.usage {
123        if entry.admitted_at() > input.trusted_time {
124            return Err(AllowanceEvaluationBlock::FutureUsage);
125        }
126        if entry.amount().asset() != input.terms.asset() {
127            return Err(AllowanceEvaluationBlock::UsageAssetMismatch);
128        }
129    }
130    Ok(())
131}
132
133fn check_lifetime_limit(
134    input: &AllowanceEvaluationInput<'_>,
135) -> std::result::Result<(), AllowanceEvaluationBlock> {
136    if let Some(limit) = input.terms.lifetime_amount_limit() {
137        let total = input
138            .usage
139            .iter()
140            .fold(input.request.amount().value().to_owned(), |sum, entry| {
141                add_decimals(&sum, entry.amount().value())
142            });
143        if compare_decimals(&total, limit).is_gt() {
144            return Err(AllowanceEvaluationBlock::LifetimeAmountLimit);
145        }
146    }
147    Ok(())
148}
149
150fn check_period_limits(
151    input: &AllowanceEvaluationInput<'_>,
152) -> std::result::Result<(), AllowanceEvaluationBlock> {
153    for (index, limit) in input.terms.period_limits().iter().enumerate() {
154        let window = allowance_period_window(limit.period(), input.trusted_time)?;
155        let mut total = input.request.amount().value().to_owned();
156        let mut count = 1_u64;
157        for entry in input
158            .usage
159            .iter()
160            .filter(|entry| window.contains(entry.admitted_at()))
161        {
162            total = add_decimals(&total, entry.amount().value());
163            count = count
164                .checked_add(1)
165                .ok_or(AllowanceEvaluationBlock::ArithmeticOverflow)?;
166        }
167        if limit
168            .amount_limit()
169            .is_some_and(|amount| compare_decimals(&total, amount).is_gt())
170        {
171            return Err(AllowanceEvaluationBlock::PeriodAmountLimit { index });
172        }
173        if limit
174            .payment_count_limit()
175            .is_some_and(|maximum| count > maximum)
176        {
177            return Err(AllowanceEvaluationBlock::PeriodCountLimit { index });
178        }
179    }
180    Ok(())
181}