paykit-lib 0.1.0

Paykit library implementation
Documentation
use chrono::{DateTime, Utc};

use super::*;
use crate::{PaymentAmount, PaymentEndpointIdentifier, PaymentReference, PaymentRequestTerms};

fn time(value: &str) -> DateTime<Utc> {
    DateTime::parse_from_rfc3339(value)
        .unwrap()
        .with_timezone(&Utc)
}

fn endpoint(value: &str) -> PaymentEndpointIdentifier {
    PaymentEndpointIdentifier::new(value).unwrap()
}

fn request(value: &str, asset: &str) -> PaymentRequestTerms {
    PaymentRequestTerms::builder(
        PaymentAmount::new(value, asset).unwrap(),
        PaymentReference::new("invoice-1").unwrap(),
        vec![
            endpoint("btc-lightning-bolt11"),
            endpoint("btc-lightning-bolt12"),
        ],
    )
    .build()
    .unwrap()
}

fn usage(value: &str, admitted_at: DateTime<Utc>) -> AllowanceUsageEntry {
    AllowanceUsageEntry::new(PaymentAmount::new(value, "btc").unwrap(), admitted_at).unwrap()
}

fn evaluate(
    terms: &AllowanceTerms,
    value: &str,
    now: DateTime<Utc>,
    usage: &[AllowanceUsageEntry],
) -> std::result::Result<AllowanceEvaluation, AllowanceEvaluationBlock> {
    evaluate_allowance(&AllowanceEvaluationInput {
        terms,
        request: &request(value, "btc"),
        trusted_time: now,
        watermark: now,
        usage,
    })
}

#[test]
fn test_static_matching_requires_exact_asset_and_inclusive_amount() {
    let terms = AllowanceTerms::builder("btc")
        .per_payment_amount(AllowanceAmountRange::new(".5", "1.00").unwrap())
        .build()
        .unwrap();
    for value in ["0.500", "01."] {
        assert!(match_allowance_request(&terms, &request(value, "btc")).is_ok());
    }
    for value in [".4999999999999999999999", "1.000000000000000000001"] {
        assert_eq!(
            match_allowance_request(&terms, &request(value, "btc")),
            Err(AllowanceEvaluationBlock::AmountOutsideRange)
        );
    }
    assert_eq!(
        match_allowance_request(&terms, &request("1", "BTC")),
        Err(AllowanceEvaluationBlock::AssetMismatch)
    );
}

#[test]
fn test_static_matching_intersects_endpoint_identifiers() {
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("10")
        .allowed_payment_endpoint_identifiers(vec![endpoint("btc-lightning-bolt12")])
        .build()
        .unwrap();
    assert_eq!(
        match_allowance_request(&terms, &request("1", "btc")).unwrap(),
        vec![endpoint("btc-lightning-bolt12")]
    );
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("10")
        .allowed_payment_endpoint_identifiers(vec![endpoint("btc-onchain")])
        .build()
        .unwrap();
    assert_eq!(
        match_allowance_request(&terms, &request("1", "btc")),
        Err(AllowanceEvaluationBlock::NoEligibleEndpoint)
    );
}

#[test]
fn test_active_window_includes_start_and_excludes_expiry() {
    let terms = AllowanceTerms::builder("btc")
        .active_from("2026-01-01T00:00:00Z")
        .expires_at("2026-02-01T00:00:00Z")
        .build()
        .unwrap();
    assert_eq!(
        evaluate(&terms, "1", time("2025-12-31T23:59:59Z"), &[]),
        Err(AllowanceEvaluationBlock::NotActive)
    );
    assert!(evaluate(&terms, "1", time("2026-01-01T00:00:00Z"), &[]).is_ok());
    assert_eq!(
        evaluate(&terms, "1", time("2026-02-01T00:00:00Z"), &[]),
        Err(AllowanceEvaluationBlock::Expired)
    );
}

#[test]
fn test_candidate_checks_do_not_require_current_capacity() {
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("0")
        .build()
        .unwrap();
    let now = time("2026-01-01T00:00:00Z");
    assert!(match_allowance_request(&terms, &request("1", "btc")).is_ok());
    assert!(check_allowance_time(&terms, now, now).is_ok());
    assert_eq!(
        evaluate(&terms, "1", now, &[]),
        Err(AllowanceEvaluationBlock::LifetimeAmountLimit)
    );
}

#[test]
fn test_rolling_window_excludes_lower_boundary_and_includes_now() {
    let now = time("2026-01-02T00:00:00Z");
    let period = AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap();
    let window = allowance_period_window(&period, now).unwrap();
    assert!(!window.contains(time("2026-01-01T00:00:00Z")));
    assert!(window.contains(time("2026-01-01T00:00:00.000000001Z")));
    assert!(window.contains(now));
    let terms = AllowanceTerms::builder("btc")
        .period_limits(vec![AllowancePeriodLimit::new(
            Some("2".into()),
            Some(2),
            period,
        )
        .unwrap()])
        .build()
        .unwrap();
    assert!(evaluate(
        &terms,
        "1",
        now,
        &[usage("100", window.starts_at()), usage("1", now)]
    )
    .is_ok());
    assert_eq!(
        evaluate(&terms, "1", now, &[usage("2", now)]),
        Err(AllowanceEvaluationBlock::PeriodAmountLimit { index: 0 })
    );
}

#[test]
fn test_every_period_and_lifetime_limit_applies() {
    let now = time("2026-01-02T00:00:00Z");
    let periods = vec![
        AllowancePeriodLimit::new(
            Some("10".into()),
            None,
            AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap(),
        )
        .unwrap(),
        AllowancePeriodLimit::new(
            None,
            Some(1),
            AllowancePeriod::rolling(1, AllowancePeriodUnit::Week).unwrap(),
        )
        .unwrap(),
    ];
    let terms = AllowanceTerms::builder("btc")
        .period_limits(periods)
        .lifetime_amount_limit("100")
        .build()
        .unwrap();
    assert_eq!(
        evaluate(&terms, "1", now, &[usage("1", now)]),
        Err(AllowanceEvaluationBlock::PeriodCountLimit { index: 1 })
    );
    assert_eq!(
        evaluate(
            &terms,
            "1",
            now,
            &[usage("100", time("2025-01-01T00:00:00Z"))]
        ),
        Err(AllowanceEvaluationBlock::LifetimeAmountLimit)
    );
}

#[test]
fn test_zero_count_limit_blocks_even_zero_amount_candidate() {
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("10")
        .period_limits(vec![AllowancePeriodLimit::new(
            None,
            Some(0),
            AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap(),
        )
        .unwrap()])
        .build()
        .unwrap();
    assert_eq!(
        evaluate(&terms, "0", time("2026-01-01T00:00:00Z"), &[]),
        Err(AllowanceEvaluationBlock::PeriodCountLimit { index: 0 })
    );
}

#[test]
fn test_usage_and_candidate_amounts_are_added_without_rounding() {
    let now = time("2026-01-01T00:00:00Z");
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("1.000000000000000000000000000001")
        .build()
        .unwrap();
    assert!(evaluate(
        &terms,
        ".000000000000000000000000000001",
        now,
        &[usage("1", now)]
    )
    .is_ok());
    assert_eq!(
        evaluate(
            &terms,
            ".000000000000000000000000000002",
            now,
            &[usage("1", now)]
        ),
        Err(AllowanceEvaluationBlock::LifetimeAmountLimit)
    );
}

#[test]
fn test_watermark_and_future_usage_block_capacity_reopening() {
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("100")
        .build()
        .unwrap();
    let request = request("1", "btc");
    let now = time("2026-01-01T09:00:00Z");
    let later = time("2026-01-01T10:00:00Z");
    let entries = [usage("1", later)];
    let mut input = AllowanceEvaluationInput {
        terms: &terms,
        request: &request,
        trusted_time: now,
        watermark: later,
        usage: &entries,
    };
    assert_eq!(
        evaluate_allowance(&input),
        Err(AllowanceEvaluationBlock::ClockRollback)
    );
    input.watermark = now;
    assert_eq!(
        evaluate_allowance(&input),
        Err(AllowanceEvaluationBlock::FutureUsage)
    );
    input.trusted_time = later;
    assert!(evaluate_allowance(&input).is_ok());
}

#[test]
fn test_usage_asset_mismatch_fails_closed_without_leaking_amount() {
    let terms = AllowanceTerms::builder("btc")
        .lifetime_amount_limit("100")
        .build()
        .unwrap();
    let now = time("2026-01-01T00:00:00Z");
    let entry =
        AllowanceUsageEntry::new(PaymentAmount::new("123.456", "BTC").unwrap(), now).unwrap();
    assert!(!format!("{entry:?}").contains("123.456"));
    assert_eq!(
        evaluate(&terms, "1", now, &[entry]),
        Err(AllowanceEvaluationBlock::UsageAssetMismatch)
    );
}

#[test]
fn test_calendar_period_capacity_does_not_reopen_when_clock_crosses_midnight_backwards() {
    let terms = AllowanceTerms::builder("btc")
        .period_limits(vec![AllowancePeriodLimit::new(
            Some("1".into()),
            None,
            AllowancePeriod::anchored(1, AllowancePeriodUnit::Day, "2026-01-01T00:00:00Z").unwrap(),
        )
        .unwrap()])
        .build()
        .unwrap();
    let now = time("2026-01-02T00:00:00Z");
    let request = request("1", "btc");
    let usage = [usage("1", now)];
    let input = AllowanceEvaluationInput {
        terms: &terms,
        request: &request,
        trusted_time: time("2026-01-01T23:59:59Z"),
        watermark: now,
        usage: &usage,
    };
    assert_eq!(
        evaluate_allowance(&input),
        Err(AllowanceEvaluationBlock::ClockRollback)
    );
    assert!(evaluate(&terms, "1", time("2026-01-03T00:00:00Z"), &usage).is_ok());
}

mod periods;

#[test]
fn test_allowance_matching_excludes_explicitly_priced_endpoints() {
    let terms = AllowanceTerms::builder("usdt")
        .lifetime_amount_limit("100")
        .build()
        .unwrap();
    let request = PaymentRequestTerms::builder(
        PaymentAmount::new("10", "usdt").unwrap(),
        PaymentReference::new("invoice-1").unwrap(),
        vec![
            endpoint("usdt-polygon-address"),
            endpoint("usdt-arbitrum-address"),
        ],
    )
    .conversion(Some(crate::PaymentConversion::Fixed {
        rates: vec![crate::ConversionRate {
            asset: "usdt-polygon".into(),
            value: "1.008".into(),
        }],
    }))
    .build()
    .unwrap();
    assert_eq!(
        match_allowance_request(&terms, &request).unwrap(),
        vec![endpoint("usdt-arbitrum-address")]
    );
    let restricted = AllowanceTerms::builder("usdt")
        .lifetime_amount_limit("100")
        .allowed_payment_endpoint_identifiers(vec![endpoint("usdt-polygon-address")])
        .build()
        .unwrap();
    assert_eq!(
        match_allowance_request(&restricted, &request),
        Err(AllowanceEvaluationBlock::NoEligibleEndpoint)
    );
}