use std::{collections::HashSet, fmt, hash::Hash};
use super::amounts::compare_decimals;
use crate::{
validation::{
parse_utc_timestamp, validate_asset_text, validate_decimal_text, validate_uuid_v4,
},
EventId, PaykitError, PaymentEndpointIdentifier, PrivateMessageKind, Result,
};
const ALLOWANCE_V1_VERSION: u8 = 1;
macro_rules! redacted_debug {
($($ty:ident),* $(,)?) => {$(
impl fmt::Debug for $ty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(concat!(stringify!($ty), "(<redacted>)"))
}
}
)*};
}
redacted_debug!(
AllowanceAmountRange,
AllowancePeriod,
AllowancePeriodLimit,
AllowanceTerms,
AllowanceTermsBuilder,
);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct AllowanceId(String);
impl AllowanceId {
pub fn new(id: impl Into<String>) -> Result<Self> {
validate_uuid_v4(id.into(), "Allowance ID").map(Self)
}
pub fn new_v4() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AllowanceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for AllowanceId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AllowanceRole {
Allower,
Allowee,
}
impl AllowanceRole {
pub fn as_str(self) -> &'static str {
match self {
Self::Allower => "allower",
Self::Allowee => "allowee",
}
}
pub(crate) fn counterparty(self) -> Self {
match self {
Self::Allower => Self::Allowee,
Self::Allowee => Self::Allower,
}
}
pub(super) fn parse(value: &str) -> Result<Self> {
match value {
"allower" => Ok(Self::Allower),
"allowee" => Ok(Self::Allowee),
_ => Err(PaykitError::Validation(
"Allowance proposer_role must be allower or allowee".into(),
)),
}
}
}
impl fmt::Display for AllowanceRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct AllowanceAmountRange {
minimum: String,
maximum: String,
}
impl AllowanceAmountRange {
pub fn new(minimum: impl Into<String>, maximum: impl Into<String>) -> Result<Self> {
let minimum = minimum.into();
let maximum = maximum.into();
validate_decimal_text(&minimum, "Allowance minimum")?;
validate_decimal_text(&maximum, "Allowance maximum")?;
if compare_decimals(&minimum, &maximum).is_gt() {
return Err(PaykitError::Validation(
"Allowance minimum must not exceed maximum".into(),
));
}
Ok(Self { minimum, maximum })
}
pub fn minimum(&self) -> &str {
&self.minimum
}
pub fn maximum(&self) -> &str {
&self.maximum
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AllowancePeriodKind {
Anchored,
Rolling,
}
impl AllowancePeriodKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Anchored => "anchored",
Self::Rolling => "rolling",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AllowancePeriodUnit {
Minute,
Hour,
Day,
Week,
Month,
Year,
}
impl AllowancePeriodUnit {
pub fn as_str(self) -> &'static str {
match self {
Self::Minute => "minute",
Self::Hour => "hour",
Self::Day => "day",
Self::Week => "week",
Self::Month => "month",
Self::Year => "year",
}
}
pub(super) fn parse(value: &str) -> Result<Self> {
match value {
"minute" => Ok(Self::Minute),
"hour" => Ok(Self::Hour),
"day" => Ok(Self::Day),
"week" => Ok(Self::Week),
"month" => Ok(Self::Month),
"year" => Ok(Self::Year),
_ => Err(PaykitError::Validation(
"Allowance period unit is unsupported".into(),
)),
}
}
}
impl fmt::Display for AllowancePeriodUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct AllowancePeriod {
every: u64,
unit: AllowancePeriodUnit,
anchor: Option<String>,
}
impl AllowancePeriod {
pub fn anchored(
every: u64,
unit: AllowancePeriodUnit,
anchor: impl Into<String>,
) -> Result<Self> {
if every == 0 {
return Err(PaykitError::Validation(
"Allowance period every must be positive".into(),
));
}
let anchor = anchor.into();
parse_utc_timestamp(&anchor, "Allowance period anchor")?;
Ok(Self {
every,
unit,
anchor: Some(anchor),
})
}
pub fn rolling(every: u64, unit: AllowancePeriodUnit) -> Result<Self> {
if every == 0 {
return Err(PaykitError::Validation(
"Allowance period every must be positive".into(),
));
}
if matches!(unit, AllowancePeriodUnit::Month | AllowancePeriodUnit::Year) {
return Err(PaykitError::Validation(
"rolling Allowance periods do not support month or year".into(),
));
}
Ok(Self {
every,
unit,
anchor: None,
})
}
pub fn kind(&self) -> AllowancePeriodKind {
if self.anchor.is_some() {
AllowancePeriodKind::Anchored
} else {
AllowancePeriodKind::Rolling
}
}
pub fn every(&self) -> u64 {
self.every
}
pub fn unit(&self) -> AllowancePeriodUnit {
self.unit
}
pub fn anchor(&self) -> Option<&str> {
self.anchor.as_deref()
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct AllowancePeriodLimit {
amount_limit: Option<String>,
payment_count_limit: Option<u64>,
period: AllowancePeriod,
}
impl AllowancePeriodLimit {
pub fn new(
amount_limit: Option<String>,
payment_count_limit: Option<u64>,
period: AllowancePeriod,
) -> Result<Self> {
if amount_limit.is_none() && payment_count_limit.is_none() {
return Err(PaykitError::Validation(
"Allowance period limit must configure an amount or payment count".into(),
));
}
if let Some(amount) = &amount_limit {
validate_decimal_text(amount, "Allowance period amount_limit")?;
}
Ok(Self {
amount_limit,
payment_count_limit,
period,
})
}
pub fn amount_limit(&self) -> Option<&str> {
self.amount_limit.as_deref()
}
pub fn payment_count_limit(&self) -> Option<u64> {
self.payment_count_limit
}
pub fn period(&self) -> &AllowancePeriod {
&self.period
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct AllowanceTerms {
pub(super) asset: String,
pub(super) per_payment_amount: Option<AllowanceAmountRange>,
pub(super) period_limits: Vec<AllowancePeriodLimit>,
pub(super) lifetime_amount_limit: Option<String>,
pub(super) active_from: Option<String>,
pub(super) expires_at: Option<String>,
pub(super) allowed_payment_endpoint_identifiers: Option<Vec<PaymentEndpointIdentifier>>,
}
impl AllowanceTerms {
pub fn builder(asset: impl Into<String>) -> AllowanceTermsBuilder {
AllowanceTermsBuilder::new(asset)
}
pub fn asset(&self) -> &str {
&self.asset
}
pub fn per_payment_amount(&self) -> Option<&AllowanceAmountRange> {
self.per_payment_amount.as_ref()
}
pub fn period_limits(&self) -> &[AllowancePeriodLimit] {
&self.period_limits
}
pub fn lifetime_amount_limit(&self) -> Option<&str> {
self.lifetime_amount_limit.as_deref()
}
pub fn active_from(&self) -> Option<&str> {
self.active_from.as_deref()
}
pub fn expires_at(&self) -> Option<&str> {
self.expires_at.as_deref()
}
pub fn allowed_payment_endpoint_identifiers(&self) -> Option<&[PaymentEndpointIdentifier]> {
self.allowed_payment_endpoint_identifiers.as_deref()
}
pub(super) fn validate(&self) -> Result<()> {
validate_asset_text(&self.asset, "Allowance asset")?;
if let Some(limit) = &self.lifetime_amount_limit {
validate_decimal_text(limit, "Allowance lifetime_amount_limit")?;
}
validate_time_window(self.active_from.as_deref(), self.expires_at.as_deref())?;
validate_unique_period_limits(&self.period_limits)?;
validate_allowlist(self.allowed_payment_endpoint_identifiers.as_deref())?;
if self.per_payment_amount.is_none()
&& !self
.period_limits
.iter()
.any(|limit| limit.amount_limit().is_some())
&& self.lifetime_amount_limit.is_none()
&& self.expires_at.is_none()
{
return Err(PaykitError::Validation(
"Allowance Terms must include an amount ceiling or expiry".into(),
));
}
Ok(())
}
}
#[derive(Clone)]
pub struct AllowanceTermsBuilder(AllowanceTerms);
impl AllowanceTermsBuilder {
pub fn new(asset: impl Into<String>) -> Self {
Self(AllowanceTerms {
asset: asset.into(),
per_payment_amount: None,
period_limits: Vec::new(),
lifetime_amount_limit: None,
active_from: None,
expires_at: None,
allowed_payment_endpoint_identifiers: None,
})
}
pub fn per_payment_amount(mut self, range: AllowanceAmountRange) -> Self {
self.0.per_payment_amount = Some(range);
self
}
pub fn period_limits(mut self, limits: Vec<AllowancePeriodLimit>) -> Self {
self.0.period_limits = limits;
self
}
pub fn lifetime_amount_limit(mut self, limit: impl Into<String>) -> Self {
self.0.lifetime_amount_limit = Some(limit.into());
self
}
pub fn active_from(mut self, active_from: impl Into<String>) -> Self {
self.0.active_from = Some(active_from.into());
self
}
pub fn expires_at(mut self, expires_at: impl Into<String>) -> Self {
self.0.expires_at = Some(expires_at.into());
self
}
pub fn allowed_payment_endpoint_identifiers(
mut self,
identifiers: Vec<PaymentEndpointIdentifier>,
) -> Self {
self.0.allowed_payment_endpoint_identifiers = Some(identifiers);
self
}
pub fn build(self) -> Result<AllowanceTerms> {
self.0.validate()?;
Ok(self.0)
}
}
macro_rules! event_header_accessors {
($kind:expr) => {
pub fn version(&self) -> u8 {
ALLOWANCE_V1_VERSION
}
pub fn kind(&self) -> PrivateMessageKind {
$kind
}
pub fn event_id(&self) -> &EventId {
&self.event_id
}
pub fn allowance_id(&self) -> &AllowanceId {
&self.allowance_id
}
};
}
macro_rules! event_accessors {
($kind:expr) => {
event_header_accessors!($kind);
pub fn proposal_event_id(&self) -> &EventId {
&self.proposal_event_id
}
};
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllowanceProposal {
event_id: EventId,
allowance_id: AllowanceId,
proposer_role: AllowanceRole,
terms: AllowanceTerms,
}
impl AllowanceProposal {
pub fn new(
event_id: EventId,
allowance_id: AllowanceId,
proposer_role: AllowanceRole,
terms: AllowanceTerms,
) -> Self {
Self {
event_id,
allowance_id,
proposer_role,
terms,
}
}
event_header_accessors!(PrivateMessageKind::AllowanceProposal);
pub fn proposer_role(&self) -> AllowanceRole {
self.proposer_role
}
pub fn recipient_role(&self) -> AllowanceRole {
self.proposer_role.counterparty()
}
pub fn terms(&self) -> &AllowanceTerms {
&self.terms
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllowanceAcceptance {
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
}
impl AllowanceAcceptance {
pub fn new(
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
) -> Result<Self> {
validate_distinct_event_ids(&event_id, &proposal_event_id, None)?;
Ok(Self {
event_id,
allowance_id,
proposal_event_id,
})
}
event_accessors!(PrivateMessageKind::AllowanceAcceptance);
pub fn validate_for_proposal(
&self,
proposal: &AllowanceProposal,
authenticated_sender_role: AllowanceRole,
) -> Result<()> {
validate_response(
&self.allowance_id,
&self.proposal_event_id,
proposal,
authenticated_sender_role,
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllowanceRejection {
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
}
impl AllowanceRejection {
pub fn new(
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
) -> Result<Self> {
validate_distinct_event_ids(&event_id, &proposal_event_id, None)?;
Ok(Self {
event_id,
allowance_id,
proposal_event_id,
})
}
event_accessors!(PrivateMessageKind::AllowanceRejection);
pub fn validate_for_proposal(
&self,
proposal: &AllowanceProposal,
authenticated_sender_role: AllowanceRole,
) -> Result<()> {
validate_response(
&self.allowance_id,
&self.proposal_event_id,
proposal,
authenticated_sender_role,
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllowanceEnd {
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
acceptance_event_id: Option<EventId>,
}
impl AllowanceEnd {
pub fn withdrawal(
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
) -> Result<Self> {
Self::new(event_id, allowance_id, proposal_event_id, None)
}
pub fn accepted(
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
acceptance_event_id: EventId,
) -> Result<Self> {
Self::new(
event_id,
allowance_id,
proposal_event_id,
Some(acceptance_event_id),
)
}
pub(super) fn new(
event_id: EventId,
allowance_id: AllowanceId,
proposal_event_id: EventId,
acceptance_event_id: Option<EventId>,
) -> Result<Self> {
validate_distinct_event_ids(&event_id, &proposal_event_id, acceptance_event_id.as_ref())?;
Ok(Self {
event_id,
allowance_id,
proposal_event_id,
acceptance_event_id,
})
}
event_accessors!(PrivateMessageKind::AllowanceEnd);
pub fn acceptance_event_id(&self) -> Option<&EventId> {
self.acceptance_event_id.as_ref()
}
pub fn validate_withdrawal_for_proposal(
&self,
proposal: &AllowanceProposal,
authenticated_sender_role: AllowanceRole,
) -> Result<()> {
validate_common_correlation(&self.allowance_id, &self.proposal_event_id, proposal)?;
if self.acceptance_event_id.is_some() {
return Err(PaykitError::Validation(
"Allowance withdrawal must not reference an Acceptance Event".into(),
));
}
if authenticated_sender_role != proposal.proposer_role {
return Err(PaykitError::Validation(
"Allowance withdrawal sender must be the proposal sender".into(),
));
}
Ok(())
}
pub fn validate_for_accepted_allowance(
&self,
proposal: &AllowanceProposal,
acceptance: &AllowanceAcceptance,
acceptance_authenticated_sender_role: AllowanceRole,
) -> Result<()> {
acceptance.validate_for_proposal(proposal, acceptance_authenticated_sender_role)?;
validate_common_correlation(&self.allowance_id, &self.proposal_event_id, proposal)?;
if self.acceptance_event_id.as_ref() != Some(&acceptance.event_id) {
return Err(PaykitError::Validation(
"Allowance End must reference the bound Acceptance Event".into(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AllowanceEvent {
Proposal(AllowanceProposal),
Acceptance(AllowanceAcceptance),
Rejection(AllowanceRejection),
End(AllowanceEnd),
}
impl AllowanceEvent {
pub fn kind(&self) -> PrivateMessageKind {
match self {
Self::Proposal(event) => event.kind(),
Self::Acceptance(event) => event.kind(),
Self::Rejection(event) => event.kind(),
Self::End(event) => event.kind(),
}
}
pub fn event_id(&self) -> &EventId {
match self {
Self::Proposal(event) => &event.event_id,
Self::Acceptance(event) => &event.event_id,
Self::Rejection(event) => &event.event_id,
Self::End(event) => &event.event_id,
}
}
pub fn allowance_id(&self) -> &AllowanceId {
match self {
Self::Proposal(event) => &event.allowance_id,
Self::Acceptance(event) => &event.allowance_id,
Self::Rejection(event) => &event.allowance_id,
Self::End(event) => &event.allowance_id,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct AllowanceEventMessage {
pub(super) app_id: Option<crate::PaykitAppId>,
pub(super) kind: PrivateMessageKind,
pub(super) event_id: Option<EventId>,
pub(super) allowance_id: Option<AllowanceId>,
pub(super) raw_json: String,
pub(super) event: std::result::Result<AllowanceEvent, String>,
}
impl AllowanceEventMessage {
pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
self.app_id.as_ref()
}
pub fn kind(&self) -> PrivateMessageKind {
self.kind
}
pub fn is_valid(&self) -> bool {
self.event.is_ok()
}
pub fn parsed_event(&self) -> Option<&AllowanceEvent> {
self.event.as_ref().ok()
}
pub fn validation_error(&self) -> Option<&str> {
self.event.as_ref().err().map(String::as_str)
}
pub fn event_id(&self) -> Option<&EventId> {
self.event_id.as_ref()
}
pub fn allowance_id(&self) -> Option<&AllowanceId> {
self.allowance_id.as_ref()
}
pub fn raw_json(&self) -> &str {
&self.raw_json
}
}
impl fmt::Debug for AllowanceEventMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AllowanceEventMessage")
.field("kind", &self.kind)
.field("event_id", &self.event_id)
.field("allowance_id", &self.allowance_id)
.field(
"raw_json",
&format_args!("<redacted:{} bytes>", self.raw_json.len()),
)
.field(
"parsed_kind",
&self.event.as_ref().ok().map(AllowanceEvent::kind),
)
.field("validation_error", &self.validation_error())
.finish()
}
}
fn validate_response(
allowance_id: &AllowanceId,
proposal_event_id: &EventId,
proposal: &AllowanceProposal,
authenticated_sender_role: AllowanceRole,
) -> Result<()> {
validate_common_correlation(allowance_id, proposal_event_id, proposal)?;
if authenticated_sender_role != proposal.recipient_role() {
return Err(PaykitError::Validation(
"Allowance response sender must be the proposal recipient".into(),
));
}
Ok(())
}
fn validate_distinct_event_ids(
event_id: &EventId,
proposal_event_id: &EventId,
acceptance_event_id: Option<&EventId>,
) -> Result<()> {
if event_id == proposal_event_id
|| acceptance_event_id.is_some_and(|acceptance_event_id| {
acceptance_event_id == event_id || acceptance_event_id == proposal_event_id
})
{
return Err(PaykitError::Validation(
"Allowance lifecycle Event IDs must be pairwise distinct".into(),
));
}
Ok(())
}
fn validate_common_correlation(
allowance_id: &AllowanceId,
proposal_event_id: &EventId,
proposal: &AllowanceProposal,
) -> Result<()> {
if allowance_id != &proposal.allowance_id || proposal_event_id != &proposal.event_id {
return Err(PaykitError::Validation(
"Allowance event does not reference the bound proposal".into(),
));
}
Ok(())
}
fn validate_time_window(active_from: Option<&str>, expires_at: Option<&str>) -> Result<()> {
let active = active_from
.map(|value| parse_utc_timestamp(value, "Allowance active_from"))
.transpose()?;
let expires = expires_at
.map(|value| parse_utc_timestamp(value, "Allowance expires_at"))
.transpose()?;
if let (Some(active), Some(expires)) = (active, expires) {
if expires <= active {
return Err(PaykitError::Validation(
"Allowance expires_at must be after active_from".into(),
));
}
}
Ok(())
}
fn validate_unique_period_limits(limits: &[AllowancePeriodLimit]) -> Result<()> {
if !all_unique(limits) {
return Err(PaykitError::Validation(
"Allowance period_limits must contain unique entries".into(),
));
}
Ok(())
}
fn validate_allowlist(identifiers: Option<&[PaymentEndpointIdentifier]>) -> Result<()> {
let Some(identifiers) = identifiers else {
return Ok(());
};
if identifiers.is_empty() {
return Err(PaykitError::Validation(
"Allowance endpoint allowlist must not be empty".into(),
));
}
if !all_unique(identifiers) {
return Err(PaykitError::Validation(
"Allowance endpoint allowlist must contain unique identifiers".into(),
));
}
Ok(())
}
fn all_unique<T: Eq + Hash>(items: &[T]) -> bool {
let mut seen = HashSet::with_capacity(items.len());
items.iter().all(|item| seen.insert(item))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::allowance::test_fixtures::{minimal_proposal, proposal_with_terms};
#[test]
fn test_amount_range_compares_decimals_exactly() {
assert!(AllowanceAmountRange::new(
"999999999999999999999999.9",
"1000000000000000000000000"
)
.is_ok());
assert!(AllowanceAmountRange::new("1.000", "1").is_ok());
assert!(AllowanceAmountRange::new(".51", ".509").is_err());
assert!(AllowanceAmountRange::new("1", "-1").is_err());
}
#[test]
fn test_period_constructors_reject_invalid_shapes() {
assert!(AllowancePeriod::rolling(0, AllowancePeriodUnit::Day).is_err());
assert!(AllowancePeriod::rolling(1, AllowancePeriodUnit::Month).is_err());
assert!(AllowancePeriod::anchored(
1,
AllowancePeriodUnit::Month,
"2026-01-31T00:00:00+00:00"
)
.is_err());
}
#[test]
fn test_terms_reject_unconstrained_and_duplicate_rules() {
assert!(AllowanceTerms::builder("btc").build().is_err());
let period = AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap();
let limit = AllowancePeriodLimit::new(Some("1".into()), None, period).unwrap();
assert!(AllowanceTerms::builder("btc")
.period_limits(vec![limit.clone(), limit])
.build()
.is_err());
let endpoint = PaymentEndpointIdentifier::new("btc-lightning-bolt12").unwrap();
assert!(AllowanceTerms::builder("btc")
.allowed_payment_endpoint_identifiers(vec![endpoint.clone(), endpoint])
.build()
.is_err());
}
#[test]
fn test_terms_require_amount_ceiling_or_expiry() {
let endpoint = PaymentEndpointIdentifier::new("btc-lightning-bolt12").unwrap();
let period = AllowancePeriod::rolling(1, AllowancePeriodUnit::Day).unwrap();
let count_only = AllowancePeriodLimit::new(None, Some(0), period.clone()).unwrap();
let builders = [
AllowanceTerms::builder("btc").active_from("2026-01-01T00:00:00Z"),
AllowanceTerms::builder("btc")
.allowed_payment_endpoint_identifiers(vec![endpoint.clone()]),
AllowanceTerms::builder("btc").period_limits(vec![count_only.clone()]),
AllowanceTerms::builder("btc")
.active_from("2026-01-01T00:00:00Z")
.allowed_payment_endpoint_identifiers(vec![endpoint])
.period_limits(vec![count_only]),
];
for builder in builders {
assert!(matches!(builder.build(), Err(PaykitError::Validation(_))));
}
for builder in [
AllowanceTerms::builder("btc")
.per_payment_amount(AllowanceAmountRange::new("0", "0").unwrap()),
AllowanceTerms::builder("btc").period_limits(vec![AllowancePeriodLimit::new(
Some("0".into()),
None,
period,
)
.unwrap()]),
AllowanceTerms::builder("btc").lifetime_amount_limit("0"),
AllowanceTerms::builder("btc").expires_at("2000-01-01T00:00:00Z"),
] {
assert!(builder.build().is_ok());
}
}
#[test]
fn test_response_and_end_correlation_checks_roles_and_ids() {
let proposal = minimal_proposal();
let acceptance = AllowanceAcceptance::new(
EventId::new_v4(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
)
.unwrap();
assert!(acceptance
.validate_for_proposal(&proposal, AllowanceRole::Allowee)
.is_ok());
assert!(acceptance
.validate_for_proposal(&proposal, AllowanceRole::Allower)
.is_err());
assert!(AllowanceAcceptance::new(
EventId::new_v4(),
AllowanceId::new_v4(),
proposal.event_id.clone(),
)
.unwrap()
.validate_for_proposal(&proposal, AllowanceRole::Allowee)
.is_err());
assert!(AllowanceAcceptance::new(
EventId::new_v4(),
proposal.allowance_id.clone(),
EventId::new_v4(),
)
.unwrap()
.validate_for_proposal(&proposal, AllowanceRole::Allowee)
.is_err());
let end = AllowanceEnd::accepted(
EventId::new_v4(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
acceptance.event_id.clone(),
)
.unwrap();
assert!(end
.validate_for_accepted_allowance(&proposal, &acceptance, AllowanceRole::Allowee)
.is_ok());
assert!(end
.validate_for_accepted_allowance(&proposal, &acceptance, AllowanceRole::Allower)
.is_err());
let mismatched_end = AllowanceEnd::accepted(
EventId::new_v4(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
EventId::new_v4(),
)
.unwrap();
assert!(mismatched_end
.validate_for_accepted_allowance(&proposal, &acceptance, AllowanceRole::Allowee)
.is_err());
let withdrawal = AllowanceEnd::withdrawal(
EventId::new_v4(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
)
.unwrap();
assert!(withdrawal
.validate_withdrawal_for_proposal(&proposal, AllowanceRole::Allower)
.is_ok());
assert!(withdrawal
.validate_withdrawal_for_proposal(&proposal, AllowanceRole::Allowee)
.is_err());
assert!(end
.validate_withdrawal_for_proposal(&proposal, AllowanceRole::Allower)
.is_err());
}
#[test]
fn test_response_constructors_reject_reused_proposal_event_id() {
let proposal = minimal_proposal();
assert!(matches!(
AllowanceAcceptance::new(
proposal.event_id.clone(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
),
Err(PaykitError::Validation(_))
));
assert!(matches!(
AllowanceRejection::new(
proposal.event_id.clone(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
),
Err(PaykitError::Validation(_))
));
}
#[test]
fn test_end_constructors_require_pairwise_distinct_event_ids() {
let proposal = minimal_proposal();
let acceptance_event_id = EventId::new_v4();
assert!(matches!(
AllowanceEnd::withdrawal(
proposal.event_id.clone(),
proposal.allowance_id.clone(),
proposal.event_id.clone(),
),
Err(PaykitError::Validation(_))
));
for (event_id, proposal_event_id, acceptance_event_id) in [
(
proposal.event_id.clone(),
proposal.event_id.clone(),
acceptance_event_id.clone(),
),
(
acceptance_event_id.clone(),
proposal.event_id.clone(),
acceptance_event_id.clone(),
),
(
EventId::new_v4(),
proposal.event_id.clone(),
proposal.event_id.clone(),
),
] {
assert!(matches!(
AllowanceEnd::accepted(
event_id,
proposal.allowance_id.clone(),
proposal_event_id,
acceptance_event_id,
),
Err(PaykitError::Validation(_))
));
}
}
#[test]
fn test_debug_redacts_terms() {
let terms = AllowanceTerms::builder("SENTINEL_ASSET")
.lifetime_amount_limit("123456789")
.build()
.unwrap();
let debug = format!("{:?}", proposal_with_terms(terms));
assert!(!debug.contains("SENTINEL_ASSET"));
assert!(!debug.contains("123456789"));
}
}