use crate::errors::Result;
use crate::validation::{Validate, ValidationBuilder};
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, CustomerId, OrderId, PaymentId};
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum PaymentTransactionStatus {
#[default]
Pending,
Processing,
RequiresAction,
Completed,
Failed,
#[strum(serialize = "cancelled", serialize = "canceled")]
Cancelled,
Refunded,
PartiallyRefunded,
Disputed,
}
impl PaymentTransactionStatus {
#[must_use]
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
Self::Pending => matches!(next, Self::Processing | Self::Cancelled | Self::Failed),
Self::Processing => matches!(
next,
Self::RequiresAction | Self::Completed | Self::Failed | Self::Cancelled
),
Self::RequiresAction => {
matches!(next, Self::Processing | Self::Completed | Self::Failed | Self::Cancelled)
}
Self::Completed => {
matches!(next, Self::Refunded | Self::PartiallyRefunded | Self::Disputed)
}
Self::PartiallyRefunded => matches!(next, Self::Refunded | Self::Disputed),
Self::Disputed => matches!(next, Self::Completed | Self::Refunded | Self::Cancelled),
Self::Failed | Self::Cancelled | Self::Refunded => false,
}
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Failed | Self::Cancelled | Self::Refunded)
}
#[must_use]
pub const fn is_successful(self) -> bool {
matches!(self, Self::Completed | Self::PartiallyRefunded)
}
#[must_use]
pub const fn is_in_progress(self) -> bool {
matches!(self, Self::Pending | Self::Processing | Self::RequiresAction)
}
#[must_use]
pub const fn is_refundable(self) -> bool {
matches!(self, Self::Completed | Self::PartiallyRefunded)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum PaymentMethodType {
#[default]
CreditCard,
DebitCard,
#[strum(serialize = "bank_transfer", serialize = "ach")]
BankTransfer,
#[strum(serialize = "paypal")]
PayPal,
ApplePay,
GooglePay,
#[strum(serialize = "crypto", serialize = "cryptocurrency")]
Crypto,
#[strum(serialize = "stablecoin", serialize = "usdc", serialize = "usdt", serialize = "ssusd")]
Stablecoin,
StoreCredit,
GiftCard,
#[strum(serialize = "cash_on_delivery", serialize = "cod")]
CashOnDelivery,
Invoice,
Other,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum BlockchainNetwork {
#[default]
#[strum(serialize = "solana", serialize = "solana_mainnet", serialize = "mainnet-beta")]
Solana,
#[strum(serialize = "solana_devnet", serialize = "devnet")]
SolanaDevnet,
#[strum(serialize = "set_chain", serialize = "set", serialize = "ssc")]
SetChain,
#[strum(serialize = "set_chain_testnet", serialize = "set_testnet")]
SetChainTestnet,
#[strum(serialize = "ethereum", serialize = "eth")]
Ethereum,
Base,
#[strum(serialize = "arbitrum", serialize = "arb")]
Arbitrum,
Near,
#[strum(serialize = "cosmos", serialize = "atom")]
Cosmos,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(ascii_case_insensitive)]
#[non_exhaustive]
pub enum StablecoinType {
#[default]
#[strum(serialize = "USDC")]
Usdc,
#[strum(serialize = "USDT", serialize = "TETHER")]
Usdt,
#[strum(serialize = "ssUSD", serialize = "SSUSD", serialize = "SS_USD")]
SsUsd,
#[strum(serialize = "wssUSD", serialize = "WSSUSD", serialize = "WSS_USD")]
WssUsd,
#[strum(serialize = "DAI")]
Dai,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum CardBrand {
#[default]
Unknown,
Visa,
Mastercard,
#[strum(serialize = "amex", serialize = "american_express")]
Amex,
Discover,
#[strum(serialize = "diners_club", serialize = "diners")]
DinersClub,
Jcb,
#[strum(serialize = "union_pay", serialize = "unionpay")]
UnionPay,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum RefundStatus {
#[default]
Pending,
Processing,
Completed,
Failed,
#[strum(serialize = "cancelled", serialize = "canceled")]
Cancelled,
}
impl RefundStatus {
#[must_use]
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
Self::Pending => matches!(next, Self::Processing | Self::Cancelled | Self::Failed),
Self::Processing => matches!(next, Self::Completed | Self::Failed),
Self::Completed | Self::Failed | Self::Cancelled => false,
}
}
#[must_use]
pub const fn is_in_progress(self) -> bool {
matches!(self, Self::Pending | Self::Processing)
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Payment {
pub id: PaymentId,
pub payment_number: String,
pub order_id: Option<OrderId>,
pub invoice_id: Option<Uuid>,
pub customer_id: Option<CustomerId>,
pub status: PaymentTransactionStatus,
pub payment_method: PaymentMethodType,
pub amount: Decimal,
pub currency: CurrencyCode,
pub amount_refunded: Decimal,
pub external_id: Option<String>,
pub idempotency_key: Option<String>,
pub processor: Option<String>,
pub card_brand: Option<CardBrand>,
pub card_last4: Option<String>,
pub card_exp_month: Option<i32>,
pub card_exp_year: Option<i32>,
pub blockchain_network: Option<BlockchainNetwork>,
pub stablecoin_type: Option<StablecoinType>,
pub from_wallet_address: Option<String>,
pub to_wallet_address: Option<String>,
pub tx_hash: Option<String>,
pub block_number: Option<i64>,
pub confirmations: Option<i32>,
pub token_address: Option<String>,
pub ves_intent_id: Option<String>,
pub billing_email: Option<String>,
pub billing_name: Option<String>,
pub billing_address: Option<String>,
pub description: Option<String>,
pub failure_reason: Option<String>,
pub failure_code: Option<String>,
pub metadata: Option<String>,
pub paid_at: Option<DateTime<Utc>>,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Payment {
#[must_use]
pub fn refundable_remaining(&self) -> Decimal {
self.amount - self.amount_refunded
}
pub fn validate_refund(&self, requested: Option<Decimal>) -> crate::Result<Decimal> {
use crate::CommerceError;
if !self.status.is_refundable() {
return Err(CommerceError::ValidationError(format!(
"Cannot refund a payment in status '{}'; only completed or partially refunded payments are refundable",
self.status
)));
}
let remaining = self.refundable_remaining();
let amount = requested.unwrap_or(remaining);
if amount <= Decimal::ZERO {
return Err(CommerceError::ValidationError(
"Refund amount must be greater than zero".into(),
));
}
if amount > remaining {
return Err(CommerceError::ValidationError(format!(
"Refund amount {amount} exceeds remaining refundable balance {remaining}"
)));
}
Ok(amount)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreatePayment {
pub order_id: Option<OrderId>,
pub invoice_id: Option<Uuid>,
pub customer_id: Option<CustomerId>,
pub payment_method: PaymentMethodType,
pub amount: Decimal,
pub currency: Option<CurrencyCode>,
pub external_id: Option<String>,
pub idempotency_key: Option<String>,
pub processor: Option<String>,
pub card_brand: Option<CardBrand>,
pub card_last4: Option<String>,
pub card_exp_month: Option<i32>,
pub card_exp_year: Option<i32>,
pub blockchain_network: Option<BlockchainNetwork>,
pub stablecoin_type: Option<StablecoinType>,
pub from_wallet_address: Option<String>,
pub to_wallet_address: Option<String>,
pub token_address: Option<String>,
pub billing_email: Option<String>,
pub billing_name: Option<String>,
pub billing_address: Option<String>,
pub description: Option<String>,
pub metadata: Option<String>,
}
impl Validate for CreatePayment {
fn validate(&self) -> Result<()> {
ValidationBuilder::new()
.non_negative("amount", self.amount)
.email_if_present("billing_email", self.billing_email.as_deref())
.build()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdatePayment {
pub status: Option<PaymentTransactionStatus>,
pub external_id: Option<String>,
pub failure_reason: Option<String>,
pub failure_code: Option<String>,
pub metadata: Option<String>,
pub tx_hash: Option<String>,
pub block_number: Option<i64>,
pub confirmations: Option<i32>,
pub ves_intent_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PaymentFilter {
pub order_id: Option<OrderId>,
pub invoice_id: Option<Uuid>,
pub customer_id: Option<CustomerId>,
pub status: Option<PaymentTransactionStatus>,
pub payment_method: Option<PaymentMethodType>,
pub processor: Option<String>,
pub currency: Option<CurrencyCode>,
pub min_amount: Option<Decimal>,
pub max_amount: Option<Decimal>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Refund {
pub id: Uuid,
pub refund_number: String,
pub payment_id: PaymentId,
pub status: RefundStatus,
pub amount: Decimal,
pub currency: CurrencyCode,
pub reason: Option<String>,
pub external_id: Option<String>,
pub idempotency_key: Option<String>,
pub failure_reason: Option<String>,
pub notes: Option<String>,
pub refunded_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateRefund {
pub payment_id: PaymentId,
pub amount: Option<Decimal>,
pub reason: Option<String>,
pub external_id: Option<String>,
pub idempotency_key: Option<String>,
pub notes: Option<String>,
}
impl Validate for CreateRefund {
fn validate(&self) -> Result<()> {
ValidationBuilder::new().uuid_not_nil("payment_id", self.payment_id.into_uuid()).build()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentMethod {
pub id: Uuid,
pub customer_id: CustomerId,
pub method_type: PaymentMethodType,
pub is_default: bool,
pub card_brand: Option<CardBrand>,
pub card_last4: Option<String>,
pub card_exp_month: Option<i32>,
pub card_exp_year: Option<i32>,
pub cardholder_name: Option<String>,
pub bank_name: Option<String>,
pub account_last4: Option<String>,
pub wallet_address: Option<String>,
pub blockchain_network: Option<BlockchainNetwork>,
pub stablecoin_type: Option<StablecoinType>,
pub external_id: Option<String>,
pub billing_address: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreatePaymentMethod {
pub customer_id: CustomerId,
pub method_type: PaymentMethodType,
pub is_default: Option<bool>,
pub card_brand: Option<CardBrand>,
pub card_last4: Option<String>,
pub card_exp_month: Option<i32>,
pub card_exp_year: Option<i32>,
pub cardholder_name: Option<String>,
pub bank_name: Option<String>,
pub account_last4: Option<String>,
pub wallet_address: Option<String>,
pub blockchain_network: Option<BlockchainNetwork>,
pub stablecoin_type: Option<StablecoinType>,
pub external_id: Option<String>,
pub billing_address: Option<String>,
}
#[must_use]
pub fn generate_payment_number() -> String {
generate_number("PAY")
}
#[must_use]
pub fn generate_refund_number() -> String {
generate_number("REF")
}
fn generate_number(prefix: &str) -> String {
let now = chrono::Utc::now();
let timestamp = now.timestamp_millis();
let entropy = uuid::Uuid::new_v4().simple().to_string();
format!("{prefix}-{timestamp}-{}", entropy[..12].to_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payment_number_has_prefix_and_entropy_suffix() {
let value = generate_payment_number();
assert!(value.starts_with("PAY-"));
let parts: Vec<&str> = value.split('-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[2].len(), 12);
}
#[test]
fn refund_number_has_prefix_and_entropy_suffix() {
let value = generate_refund_number();
assert!(value.starts_with("REF-"));
let parts: Vec<&str> = value.split('-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[2].len(), 12);
}
#[test]
fn generated_numbers_are_not_equal() {
assert_ne!(generate_payment_number(), generate_payment_number());
assert_ne!(generate_refund_number(), generate_refund_number());
}
#[test]
fn payment_status_valid_transitions() {
use PaymentTransactionStatus::*;
assert!(Pending.can_transition_to(Processing));
assert!(Pending.can_transition_to(Cancelled));
assert!(Pending.can_transition_to(Failed));
assert!(Processing.can_transition_to(RequiresAction));
assert!(Processing.can_transition_to(Completed));
assert!(Processing.can_transition_to(Failed));
assert!(Processing.can_transition_to(Cancelled));
assert!(RequiresAction.can_transition_to(Processing));
assert!(RequiresAction.can_transition_to(Completed));
assert!(Completed.can_transition_to(Refunded));
assert!(Completed.can_transition_to(PartiallyRefunded));
assert!(Completed.can_transition_to(Disputed));
assert!(PartiallyRefunded.can_transition_to(Refunded));
assert!(PartiallyRefunded.can_transition_to(Disputed));
assert!(Disputed.can_transition_to(Completed));
assert!(Disputed.can_transition_to(Refunded));
assert!(Disputed.can_transition_to(Cancelled));
}
#[test]
fn payment_status_invalid_transitions() {
use PaymentTransactionStatus::*;
assert!(!Pending.can_transition_to(Completed));
assert!(!Pending.can_transition_to(Refunded));
assert!(!Completed.can_transition_to(Pending));
assert!(!Completed.can_transition_to(Processing));
assert!(!PartiallyRefunded.can_transition_to(Pending));
}
#[test]
fn payment_status_terminal_states() {
use PaymentTransactionStatus::*;
assert!(Failed.is_terminal());
assert!(Cancelled.is_terminal());
assert!(Refunded.is_terminal());
assert!(!Pending.is_terminal());
assert!(!Processing.is_terminal());
assert!(!Completed.is_terminal());
assert!(!Failed.can_transition_to(Pending));
assert!(!Cancelled.can_transition_to(Processing));
assert!(!Refunded.can_transition_to(Completed));
}
#[test]
fn payment_status_self_transitions() {
use PaymentTransactionStatus::*;
assert!(Pending.can_transition_to(Pending));
assert!(Processing.can_transition_to(Processing));
assert!(Failed.can_transition_to(Failed));
}
#[test]
fn payment_status_is_successful() {
use PaymentTransactionStatus::*;
assert!(Completed.is_successful());
assert!(PartiallyRefunded.is_successful());
assert!(!Pending.is_successful());
assert!(!Failed.is_successful());
assert!(!Refunded.is_successful());
}
#[test]
fn refund_status_valid_transitions() {
use RefundStatus::*;
assert!(Pending.can_transition_to(Processing));
assert!(Pending.can_transition_to(Cancelled));
assert!(Pending.can_transition_to(Failed));
assert!(Processing.can_transition_to(Completed));
assert!(Processing.can_transition_to(Failed));
}
#[test]
fn refund_status_invalid_transitions() {
use RefundStatus::*;
assert!(!Pending.can_transition_to(Completed));
assert!(!Processing.can_transition_to(Cancelled));
assert!(!Completed.can_transition_to(Pending));
assert!(!Failed.can_transition_to(Processing));
}
#[test]
fn refund_status_terminal_states() {
use RefundStatus::*;
assert!(Completed.is_terminal());
assert!(Failed.is_terminal());
assert!(Cancelled.is_terminal());
assert!(!Pending.is_terminal());
assert!(!Processing.is_terminal());
}
#[test]
fn payment_status_is_refundable() {
use PaymentTransactionStatus::*;
assert!(Completed.is_refundable());
assert!(PartiallyRefunded.is_refundable());
assert!(!Pending.is_refundable());
assert!(!Processing.is_refundable());
assert!(!RequiresAction.is_refundable());
assert!(!Failed.is_refundable());
assert!(!Cancelled.is_refundable());
assert!(!Refunded.is_refundable());
assert!(!Disputed.is_refundable());
}
fn payment_for_refund(
status: PaymentTransactionStatus,
amount: Decimal,
amount_refunded: Decimal,
) -> Payment {
let now = Utc::now();
Payment {
id: PaymentId::new(),
payment_number: generate_payment_number(),
order_id: None,
invoice_id: None,
customer_id: None,
status,
payment_method: PaymentMethodType::CreditCard,
amount,
currency: CurrencyCode::default(),
amount_refunded,
external_id: None,
idempotency_key: None,
processor: None,
card_brand: None,
card_last4: None,
card_exp_month: None,
card_exp_year: None,
blockchain_network: None,
stablecoin_type: None,
from_wallet_address: None,
to_wallet_address: None,
tx_hash: None,
block_number: None,
confirmations: None,
token_address: None,
ves_intent_id: None,
billing_email: None,
billing_name: None,
billing_address: None,
description: None,
failure_reason: None,
failure_code: None,
metadata: None,
paid_at: None,
version: 1,
created_at: now,
updated_at: now,
}
}
#[test]
fn validate_refund_resolves_full_remaining_when_amount_omitted() {
use rust_decimal_macros::dec;
let payment = payment_for_refund(PaymentTransactionStatus::Completed, dec!(100), dec!(40));
assert_eq!(payment.validate_refund(None).expect("resolved"), dec!(60));
}
#[test]
fn validate_refund_rejects_non_refundable_status() {
use rust_decimal_macros::dec;
for status in [
PaymentTransactionStatus::Pending,
PaymentTransactionStatus::Processing,
PaymentTransactionStatus::RequiresAction,
PaymentTransactionStatus::Failed,
PaymentTransactionStatus::Cancelled,
PaymentTransactionStatus::Refunded,
PaymentTransactionStatus::Disputed,
] {
let payment = payment_for_refund(status, dec!(100), dec!(0));
let err = payment
.validate_refund(Some(dec!(10)))
.expect_err("non-refundable status must be rejected");
assert!(matches!(err, crate::CommerceError::ValidationError(_)), "{status}: {err:?}");
}
}
#[test]
fn validate_refund_rejects_non_positive_amount() {
use rust_decimal_macros::dec;
let payment = payment_for_refund(PaymentTransactionStatus::Completed, dec!(100), dec!(0));
assert!(payment.validate_refund(Some(dec!(0))).is_err());
assert!(payment.validate_refund(Some(dec!(-1))).is_err());
}
#[test]
fn validate_refund_rejects_amount_exceeding_remaining() {
use rust_decimal_macros::dec;
let payment =
payment_for_refund(PaymentTransactionStatus::PartiallyRefunded, dec!(100), dec!(70));
assert!(payment.validate_refund(Some(dec!(31))).is_err());
assert_eq!(payment.validate_refund(Some(dec!(30))).expect("ok"), dec!(30));
}
#[test]
fn create_payment_rejects_negative_amount() {
use crate::Validate;
use rust_decimal_macros::dec;
let input = CreatePayment { amount: dec!(-1), ..Default::default() };
let err = input.validate().expect_err("negative amount must be rejected");
assert!(
matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "amount")
);
}
#[test]
fn create_payment_accepts_zero_and_positive_amount() {
use crate::Validate;
use rust_decimal_macros::dec;
assert!(CreatePayment { amount: dec!(0), ..Default::default() }.validate().is_ok());
assert!(CreatePayment { amount: dec!(99.99), ..Default::default() }.validate().is_ok());
}
#[test]
fn create_payment_rejects_malformed_billing_email() {
use crate::Validate;
use rust_decimal_macros::dec;
let input = CreatePayment {
amount: dec!(10),
billing_email: Some("not-an-email".to_string()),
..Default::default()
};
let err = input.validate().expect_err("malformed billing email must be rejected");
assert!(
matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "billing_email")
);
}
#[test]
fn create_payment_accepts_valid_billing_email() {
use crate::Validate;
use rust_decimal_macros::dec;
let input = CreatePayment {
amount: dec!(10),
billing_email: Some("alice@example.com".to_string()),
..Default::default()
};
assert!(input.validate().is_ok());
}
#[test]
fn create_refund_rejects_nil_payment_id() {
use crate::Validate;
use rust_decimal_macros::dec;
let input = CreateRefund {
payment_id: PaymentId::from_uuid(Uuid::nil()),
amount: Some(dec!(5)),
..Default::default()
};
let err = input.validate().expect_err("a nil payment reference must be rejected");
assert!(
matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "payment_id")
);
}
#[test]
fn create_refund_accepts_valid_payment_reference() {
use crate::Validate;
use rust_decimal_macros::dec;
let id = PaymentId::new();
assert!(
CreateRefund { payment_id: id, amount: None, ..Default::default() }.validate().is_ok()
);
assert!(
CreateRefund { payment_id: id, amount: Some(dec!(25)), ..Default::default() }
.validate()
.is_ok()
);
}
}