use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{A2AError, A2AResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PaymentTerms {
Net15,
Net30,
Net60,
Net90,
Prepaid,
}
impl PaymentTerms {
#[must_use]
pub const fn days(self) -> u32 {
match self {
Self::Net15 => 15,
Self::Net30 => 30,
Self::Net60 => 60,
Self::Net90 => 90,
Self::Prepaid => 0,
}
}
}
impl std::fmt::Display for PaymentTerms {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Net15 => write!(f, "net_15"),
Self::Net30 => write!(f, "net_30"),
Self::Net60 => write!(f, "net_60"),
Self::Net90 => write!(f, "net_90"),
Self::Prepaid => write!(f, "prepaid"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CreditStatus {
Active,
Suspended,
Closed,
Defaulted,
}
impl std::fmt::Display for CreditStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Active => write!(f, "active"),
Self::Suspended => write!(f, "suspended"),
Self::Closed => write!(f, "closed"),
Self::Defaulted => write!(f, "defaulted"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CreditTxType {
Charge,
Payment,
Adjustment,
}
impl std::fmt::Display for CreditTxType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Charge => write!(f, "charge"),
Self::Payment => write!(f, "payment"),
Self::Adjustment => write!(f, "adjustment"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditTransaction {
pub id: Uuid,
pub credit_terms_id: Uuid,
pub amount: Decimal,
pub tx_type: CreditTxType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due_date: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paid_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditTerms {
pub id: Uuid,
pub creditor_agent_id: String,
pub debtor_agent_id: String,
pub credit_limit: Decimal,
pub outstanding_balance: Decimal,
pub currency: String,
pub payment_terms: PaymentTerms,
pub status: CreditStatus,
pub min_trust_tier: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub struct CreditManager;
impl CreditManager {
#[must_use]
pub fn create_terms(
creditor_agent_id: impl Into<String>,
debtor_agent_id: impl Into<String>,
credit_limit: Decimal,
currency: impl Into<String>,
payment_terms: PaymentTerms,
min_trust_tier: impl Into<String>,
) -> CreditTerms {
let now = Utc::now();
CreditTerms {
id: Uuid::new_v4(),
creditor_agent_id: creditor_agent_id.into(),
debtor_agent_id: debtor_agent_id.into(),
credit_limit,
outstanding_balance: Decimal::ZERO,
currency: currency.into(),
payment_terms,
status: CreditStatus::Active,
min_trust_tier: min_trust_tier.into(),
created_at: now,
updated_at: now,
}
}
pub fn charge(
terms: &mut CreditTerms,
amount: Decimal,
reference_id: Option<String>,
) -> A2AResult<CreditTransaction> {
if terms.status != CreditStatus::Active {
return Err(A2AError::validation(format!(
"credit line is {}, charges require active status",
terms.status,
)));
}
let available = Self::available_credit(terms);
if amount > available {
return Err(A2AError::SpendingLimitExceeded {
limit_type: "credit_limit".into(),
limit: terms.credit_limit,
attempted: amount,
});
}
let now = Utc::now();
let due_date = now + Duration::days(i64::from(terms.payment_terms.days()));
terms.outstanding_balance += amount;
terms.updated_at = now;
Ok(CreditTransaction {
id: Uuid::new_v4(),
credit_terms_id: terms.id,
amount,
tx_type: CreditTxType::Charge,
reference_id,
due_date: Some(due_date),
paid_at: None,
notes: None,
created_at: now,
})
}
pub fn record_payment(
terms: &mut CreditTerms,
amount: Decimal,
reference_id: Option<String>,
) -> A2AResult<CreditTransaction> {
if amount > terms.outstanding_balance {
return Err(A2AError::validation(format!(
"payment of {} exceeds outstanding balance of {}",
amount, terms.outstanding_balance,
)));
}
let now = Utc::now();
terms.outstanding_balance -= amount;
terms.updated_at = now;
Ok(CreditTransaction {
id: Uuid::new_v4(),
credit_terms_id: terms.id,
amount,
tx_type: CreditTxType::Payment,
reference_id,
due_date: None,
paid_at: Some(now),
notes: None,
created_at: now,
})
}
#[must_use]
pub fn available_credit(terms: &CreditTerms) -> Decimal {
terms.credit_limit - terms.outstanding_balance
}
#[must_use]
pub fn is_overdue(transaction: &CreditTransaction) -> bool {
if transaction.paid_at.is_some() {
return false;
}
transaction.due_date.is_some_and(|due| Utc::now() > due)
}
#[must_use]
pub fn get_overdue_transactions(transactions: &[CreditTransaction]) -> Vec<&CreditTransaction> {
let now = Utc::now();
transactions
.iter()
.filter(|tx| tx.paid_at.is_none() && tx.due_date.is_some_and(|due| now > due))
.collect()
}
pub fn suspend(terms: &mut CreditTerms) {
terms.status = CreditStatus::Suspended;
terms.updated_at = Utc::now();
}
pub fn close(terms: &mut CreditTerms) -> A2AResult<()> {
if terms.outstanding_balance != Decimal::ZERO {
return Err(A2AError::validation(format!(
"cannot close credit line with outstanding balance of {}",
terms.outstanding_balance,
)));
}
terms.status = CreditStatus::Closed;
terms.updated_at = Utc::now();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn create_and_charge() {
let mut terms = CreditManager::create_terms(
"agent_creditor",
"agent_debtor",
dec!(10000),
"USD",
PaymentTerms::Net30,
"verified",
);
assert_eq!(terms.status, CreditStatus::Active);
assert_eq!(terms.outstanding_balance, Decimal::ZERO);
assert_eq!(CreditManager::available_credit(&terms), dec!(10000));
let tx = CreditManager::charge(&mut terms, dec!(2500), Some("inv_001".into()))
.expect("charge should succeed");
assert_eq!(tx.tx_type, CreditTxType::Charge);
assert_eq!(tx.amount, dec!(2500));
assert_eq!(tx.reference_id, Some("inv_001".into()));
assert!(tx.due_date.is_some());
assert_eq!(terms.outstanding_balance, dec!(2500));
assert_eq!(CreditManager::available_credit(&terms), dec!(7500));
}
#[test]
fn charge_exceeding_limit_fails() {
let mut terms = CreditManager::create_terms(
"agent_creditor",
"agent_debtor",
dec!(5000),
"USD",
PaymentTerms::Net60,
"verified",
);
let result = CreditManager::charge(&mut terms, dec!(5001), None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, A2AError::SpendingLimitExceeded { .. }),
"expected SpendingLimitExceeded, got: {err}",
);
assert_eq!(terms.outstanding_balance, Decimal::ZERO);
}
#[test]
fn payment_reduces_balance() {
let mut terms = CreditManager::create_terms(
"agent_creditor",
"agent_debtor",
dec!(10000),
"EUR",
PaymentTerms::Net90,
"trusted",
);
CreditManager::charge(&mut terms, dec!(4000), None).expect("charge should succeed");
assert_eq!(terms.outstanding_balance, dec!(4000));
let tx = CreditManager::record_payment(&mut terms, dec!(1500), Some("pay_001".into()))
.expect("payment should succeed");
assert_eq!(tx.tx_type, CreditTxType::Payment);
assert_eq!(tx.amount, dec!(1500));
assert!(tx.paid_at.is_some());
assert_eq!(terms.outstanding_balance, dec!(2500));
assert_eq!(CreditManager::available_credit(&terms), dec!(7500));
}
#[test]
fn close_with_outstanding_fails() {
let mut terms = CreditManager::create_terms(
"agent_creditor",
"agent_debtor",
dec!(10000),
"USD",
PaymentTerms::Net30,
"verified",
);
CreditManager::charge(&mut terms, dec!(100), None).expect("charge should succeed");
let result = CreditManager::close(&mut terms);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, A2AError::Validation(_)), "expected Validation error, got: {err}",);
assert_ne!(terms.status, CreditStatus::Closed);
}
}