Skip to main content

stateset_core/models/
payment.rs

1//! Payment domain models
2//!
3//! Handles payment processing, refunds, and payment method management.
4
5use crate::errors::Result;
6use crate::validation::{Validate, ValidationBuilder};
7use chrono::{DateTime, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use stateset_primitives::{CurrencyCode, CustomerId, OrderId, PaymentId};
11use strum::{Display, EnumString};
12use uuid::Uuid;
13
14/// Payment transaction status in the processing lifecycle
15#[derive(
16    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
17)]
18#[serde(rename_all = "snake_case")]
19#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
20#[non_exhaustive]
21pub enum PaymentTransactionStatus {
22    /// Payment is pending processing
23    #[default]
24    Pending,
25    /// Payment is being processed
26    Processing,
27    /// Payment requires additional action (e.g., 3D Secure)
28    RequiresAction,
29    /// Payment was successfully completed
30    Completed,
31    /// Payment failed
32    Failed,
33    /// Payment was cancelled
34    #[strum(serialize = "cancelled", serialize = "canceled")]
35    Cancelled,
36    /// Payment was refunded (fully)
37    Refunded,
38    /// Payment was partially refunded
39    PartiallyRefunded,
40    /// Payment is disputed/chargeback
41    Disputed,
42}
43
44impl PaymentTransactionStatus {
45    /// Check if a status transition is allowed.
46    #[must_use]
47    pub fn can_transition_to(self, next: Self) -> bool {
48        if self == next {
49            return true;
50        }
51        match self {
52            Self::Pending => matches!(next, Self::Processing | Self::Cancelled | Self::Failed),
53            Self::Processing => matches!(
54                next,
55                Self::RequiresAction | Self::Completed | Self::Failed | Self::Cancelled
56            ),
57            Self::RequiresAction => {
58                matches!(next, Self::Processing | Self::Completed | Self::Failed | Self::Cancelled)
59            }
60            Self::Completed => {
61                matches!(next, Self::Refunded | Self::PartiallyRefunded | Self::Disputed)
62            }
63            Self::PartiallyRefunded => matches!(next, Self::Refunded | Self::Disputed),
64            Self::Disputed => matches!(next, Self::Completed | Self::Refunded | Self::Cancelled),
65            Self::Failed | Self::Cancelled | Self::Refunded => false,
66        }
67    }
68
69    /// Returns true if this status is a terminal state.
70    #[must_use]
71    pub const fn is_terminal(self) -> bool {
72        matches!(self, Self::Failed | Self::Cancelled | Self::Refunded)
73    }
74
75    /// Returns true if this status represents a successful payment.
76    #[must_use]
77    pub const fn is_successful(self) -> bool {
78        matches!(self, Self::Completed | Self::PartiallyRefunded)
79    }
80
81    /// Returns true if this payment is still in progress.
82    #[must_use]
83    pub const fn is_in_progress(self) -> bool {
84        matches!(self, Self::Pending | Self::Processing | Self::RequiresAction)
85    }
86
87    /// Returns true if a payment in this status may be refunded.
88    ///
89    /// Only successfully captured funds can be refunded: a `Completed`
90    /// payment, or one that is already `PartiallyRefunded` (so the remaining
91    /// balance can be returned). `Pending`, `Processing`, `RequiresAction`,
92    /// `Failed`, `Cancelled`, `Disputed`, and fully `Refunded` payments are
93    /// not refundable.
94    #[must_use]
95    pub const fn is_refundable(self) -> bool {
96        matches!(self, Self::Completed | Self::PartiallyRefunded)
97    }
98}
99
100/// Payment method type
101#[derive(
102    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
103)]
104#[serde(rename_all = "snake_case")]
105#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
106#[non_exhaustive]
107pub enum PaymentMethodType {
108    /// Credit card
109    #[default]
110    CreditCard,
111    /// Debit card
112    DebitCard,
113    /// Bank transfer / ACH
114    #[strum(serialize = "bank_transfer", serialize = "ach")]
115    BankTransfer,
116    /// `PayPal`
117    #[strum(serialize = "paypal")]
118    PayPal,
119    /// Apple Pay
120    ApplePay,
121    /// Google Pay
122    GooglePay,
123    /// Cryptocurrency (native tokens)
124    #[strum(serialize = "crypto", serialize = "cryptocurrency")]
125    Crypto,
126    /// Stablecoin (USDC, USDT, ssUSD)
127    #[strum(serialize = "stablecoin", serialize = "usdc", serialize = "usdt", serialize = "ssusd")]
128    Stablecoin,
129    /// Store credit
130    StoreCredit,
131    /// Gift card
132    GiftCard,
133    /// Cash on delivery
134    #[strum(serialize = "cash_on_delivery", serialize = "cod")]
135    CashOnDelivery,
136    /// Invoice / Net terms
137    Invoice,
138    /// Other payment method
139    Other,
140}
141
142/// Blockchain network for crypto/stablecoin payments
143#[derive(
144    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
145)]
146#[serde(rename_all = "snake_case")]
147#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
148#[non_exhaustive]
149pub enum BlockchainNetwork {
150    /// Solana mainnet
151    #[default]
152    #[strum(serialize = "solana", serialize = "solana_mainnet", serialize = "mainnet-beta")]
153    Solana,
154    /// Solana devnet (testing)
155    #[strum(serialize = "solana_devnet", serialize = "devnet")]
156    SolanaDevnet,
157    /// SET Chain L2 (StateSet native)
158    #[strum(serialize = "set_chain", serialize = "set", serialize = "ssc")]
159    SetChain,
160    /// SET Chain testnet
161    #[strum(serialize = "set_chain_testnet", serialize = "set_testnet")]
162    SetChainTestnet,
163    /// Ethereum mainnet
164    #[strum(serialize = "ethereum", serialize = "eth")]
165    Ethereum,
166    /// Base L2 (Coinbase)
167    Base,
168    /// Arbitrum L2
169    #[strum(serialize = "arbitrum", serialize = "arb")]
170    Arbitrum,
171    /// NEAR Protocol
172    Near,
173    /// Cosmos Hub
174    #[strum(serialize = "cosmos", serialize = "atom")]
175    Cosmos,
176}
177
178/// Stablecoin token type
179#[derive(
180    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
181)]
182#[serde(rename_all = "snake_case")]
183#[strum(ascii_case_insensitive)]
184#[non_exhaustive]
185pub enum StablecoinType {
186    /// USD Coin
187    #[default]
188    #[strum(serialize = "USDC")]
189    Usdc,
190    /// Tether
191    #[strum(serialize = "USDT", serialize = "TETHER")]
192    Usdt,
193    /// StateSet USD (native yield-bearing stablecoin)
194    #[strum(serialize = "ssUSD", serialize = "SSUSD", serialize = "SS_USD")]
195    SsUsd,
196    /// Wrapped StateSet USD (ERC4626)
197    #[strum(serialize = "wssUSD", serialize = "WSSUSD", serialize = "WSS_USD")]
198    WssUsd,
199    /// DAI
200    #[strum(serialize = "DAI")]
201    Dai,
202}
203
204/// Card brand for credit/debit cards
205#[derive(
206    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
207)]
208#[serde(rename_all = "snake_case")]
209#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
210#[non_exhaustive]
211pub enum CardBrand {
212    #[default]
213    Unknown,
214    Visa,
215    Mastercard,
216    #[strum(serialize = "amex", serialize = "american_express")]
217    Amex,
218    Discover,
219    #[strum(serialize = "diners_club", serialize = "diners")]
220    DinersClub,
221    Jcb,
222    #[strum(serialize = "union_pay", serialize = "unionpay")]
223    UnionPay,
224}
225
226/// Refund status
227#[derive(
228    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
229)]
230#[serde(rename_all = "snake_case")]
231#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
232#[non_exhaustive]
233pub enum RefundStatus {
234    /// Refund is pending
235    #[default]
236    Pending,
237    /// Refund is being processed
238    Processing,
239    /// Refund completed successfully
240    Completed,
241    /// Refund failed
242    Failed,
243    /// Refund was cancelled
244    #[strum(serialize = "cancelled", serialize = "canceled")]
245    Cancelled,
246}
247
248impl RefundStatus {
249    /// Check if a status transition is allowed.
250    #[must_use]
251    pub fn can_transition_to(self, next: Self) -> bool {
252        if self == next {
253            return true;
254        }
255        match self {
256            Self::Pending => matches!(next, Self::Processing | Self::Cancelled | Self::Failed),
257            Self::Processing => matches!(next, Self::Completed | Self::Failed),
258            Self::Completed | Self::Failed | Self::Cancelled => false,
259        }
260    }
261
262    /// Returns true if this refund is still in progress.
263    #[must_use]
264    pub const fn is_in_progress(self) -> bool {
265        matches!(self, Self::Pending | Self::Processing)
266    }
267
268    /// Returns true if this status is a terminal state.
269    #[must_use]
270    pub const fn is_terminal(self) -> bool {
271        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
272    }
273}
274
275/// A payment transaction
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct Payment {
278    /// Unique payment ID
279    pub id: PaymentId,
280    /// Human-readable payment number
281    pub payment_number: String,
282    /// Associated order ID (optional - can be standalone payment)
283    pub order_id: Option<OrderId>,
284    /// Associated invoice ID (optional)
285    pub invoice_id: Option<Uuid>,
286    /// Customer ID
287    pub customer_id: Option<CustomerId>,
288    /// Payment status
289    pub status: PaymentTransactionStatus,
290    /// Payment method used
291    pub payment_method: PaymentMethodType,
292    /// Payment amount
293    pub amount: Decimal,
294    /// Currency code (ISO 4217)
295    pub currency: CurrencyCode,
296    /// Amount refunded
297    pub amount_refunded: Decimal,
298    /// External payment processor ID (e.g., Stripe payment intent ID)
299    pub external_id: Option<String>,
300    /// Idempotency key for safely retrying payment creation
301    pub idempotency_key: Option<String>,
302    /// Payment processor/gateway used
303    pub processor: Option<String>,
304    /// Card brand (if card payment)
305    pub card_brand: Option<CardBrand>,
306    /// Last 4 digits of card (if card payment)
307    pub card_last4: Option<String>,
308    /// Card expiry month (if card payment)
309    pub card_exp_month: Option<i32>,
310    /// Card expiry year (if card payment)
311    pub card_exp_year: Option<i32>,
312    // =========================================================================
313    // Blockchain/Stablecoin Payment Fields
314    // =========================================================================
315    /// Blockchain network (for crypto/stablecoin payments)
316    pub blockchain_network: Option<BlockchainNetwork>,
317    /// Stablecoin type (USDC, USDT, ssUSD, etc.)
318    pub stablecoin_type: Option<StablecoinType>,
319    /// Sender wallet address
320    pub from_wallet_address: Option<String>,
321    /// Recipient wallet address
322    pub to_wallet_address: Option<String>,
323    /// On-chain transaction hash/signature
324    pub tx_hash: Option<String>,
325    /// Block number where transaction was confirmed
326    pub block_number: Option<i64>,
327    /// Number of on-chain confirmations
328    pub confirmations: Option<i32>,
329    /// Token contract/mint address (for token transfers)
330    pub token_address: Option<String>,
331    /// VES payment intent ID (for audit trail)
332    pub ves_intent_id: Option<String>,
333    // =========================================================================
334    /// Billing email
335    pub billing_email: Option<String>,
336    /// Billing name
337    pub billing_name: Option<String>,
338    /// Billing address
339    pub billing_address: Option<String>,
340    /// Payment description
341    pub description: Option<String>,
342    /// Failure reason (if failed)
343    pub failure_reason: Option<String>,
344    /// Failure code from processor
345    pub failure_code: Option<String>,
346    /// Metadata (JSON)
347    pub metadata: Option<String>,
348    /// When payment was completed
349    pub paid_at: Option<DateTime<Utc>>,
350    /// Version for optimistic locking
351    pub version: i32,
352    /// When payment was created
353    pub created_at: DateTime<Utc>,
354    /// When payment was last updated
355    pub updated_at: DateTime<Utc>,
356}
357
358impl Payment {
359    /// The amount of this payment that has not yet been refunded.
360    #[must_use]
361    pub fn refundable_remaining(&self) -> Decimal {
362        self.amount - self.amount_refunded
363    }
364
365    /// Validate and resolve a refund request against this payment.
366    ///
367    /// `requested` is the caller-supplied refund amount; when `None` the full
368    /// remaining refundable balance is used. Returns the resolved refund amount
369    /// on success.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`crate::CommerceError::ValidationError`] when:
374    /// - the payment is not in a refundable status (only `Completed` /
375    ///   `PartiallyRefunded` payments can be refunded),
376    /// - the requested (or resolved) amount is zero or negative, or
377    /// - the requested amount exceeds the remaining refundable balance.
378    pub fn validate_refund(&self, requested: Option<Decimal>) -> crate::Result<Decimal> {
379        use crate::CommerceError;
380
381        if !self.status.is_refundable() {
382            return Err(CommerceError::ValidationError(format!(
383                "Cannot refund a payment in status '{}'; only completed or partially refunded payments are refundable",
384                self.status
385            )));
386        }
387
388        let remaining = self.refundable_remaining();
389        let amount = requested.unwrap_or(remaining);
390
391        if amount <= Decimal::ZERO {
392            return Err(CommerceError::ValidationError(
393                "Refund amount must be greater than zero".into(),
394            ));
395        }
396
397        if amount > remaining {
398            return Err(CommerceError::ValidationError(format!(
399                "Refund amount {amount} exceeds remaining refundable balance {remaining}"
400            )));
401        }
402
403        Ok(amount)
404    }
405}
406
407/// Input for creating a new payment
408#[derive(Debug, Clone, Default, Serialize, Deserialize)]
409pub struct CreatePayment {
410    /// Associated order ID
411    pub order_id: Option<OrderId>,
412    /// Associated invoice ID
413    pub invoice_id: Option<Uuid>,
414    /// Customer ID
415    pub customer_id: Option<CustomerId>,
416    /// Payment method
417    pub payment_method: PaymentMethodType,
418    /// Payment amount
419    pub amount: Decimal,
420    /// Currency code (defaults to USD)
421    pub currency: Option<CurrencyCode>,
422    /// External payment processor ID
423    pub external_id: Option<String>,
424    /// Idempotency key for safely retrying payment creation
425    pub idempotency_key: Option<String>,
426    /// Payment processor/gateway
427    pub processor: Option<String>,
428    /// Card brand
429    pub card_brand: Option<CardBrand>,
430    /// Last 4 digits of card
431    pub card_last4: Option<String>,
432    /// Card expiry month
433    pub card_exp_month: Option<i32>,
434    /// Card expiry year
435    pub card_exp_year: Option<i32>,
436    // =========================================================================
437    // Blockchain/Stablecoin Payment Fields
438    // =========================================================================
439    /// Blockchain network (solana, `set_chain`, base, etc.)
440    pub blockchain_network: Option<BlockchainNetwork>,
441    /// Stablecoin type (USDC, USDT, ssUSD)
442    pub stablecoin_type: Option<StablecoinType>,
443    /// Sender wallet address
444    pub from_wallet_address: Option<String>,
445    /// Recipient wallet address
446    pub to_wallet_address: Option<String>,
447    /// Token contract/mint address
448    pub token_address: Option<String>,
449    // =========================================================================
450    /// Billing email
451    pub billing_email: Option<String>,
452    /// Billing name
453    pub billing_name: Option<String>,
454    /// Billing address
455    pub billing_address: Option<String>,
456    /// Payment description
457    pub description: Option<String>,
458    /// Additional metadata
459    pub metadata: Option<String>,
460}
461
462impl Validate for CreatePayment {
463    /// Validate a payment create request.
464    ///
465    /// Rejects a negative amount and a malformed billing email (when present).
466    /// A zero amount is permitted (e.g. a $0 authorization / free order); only
467    /// a negative amount is rejected.
468    fn validate(&self) -> Result<()> {
469        ValidationBuilder::new()
470            .non_negative("amount", self.amount)
471            .email_if_present("billing_email", self.billing_email.as_deref())
472            .build()
473    }
474}
475
476/// Input for updating a payment
477#[derive(Debug, Clone, Default, Serialize, Deserialize)]
478pub struct UpdatePayment {
479    /// Update status
480    pub status: Option<PaymentTransactionStatus>,
481    /// Update external ID
482    pub external_id: Option<String>,
483    /// Update failure reason
484    pub failure_reason: Option<String>,
485    /// Update failure code
486    pub failure_code: Option<String>,
487    /// Update metadata
488    pub metadata: Option<String>,
489    // =========================================================================
490    // Blockchain/Stablecoin Update Fields
491    // =========================================================================
492    /// On-chain transaction hash (set after broadcast)
493    pub tx_hash: Option<String>,
494    /// Block number (set after confirmation)
495    pub block_number: Option<i64>,
496    /// Number of confirmations
497    pub confirmations: Option<i32>,
498    /// VES payment intent ID
499    pub ves_intent_id: Option<String>,
500}
501
502/// Filter for listing payments
503#[derive(Debug, Clone, Default, Serialize, Deserialize)]
504pub struct PaymentFilter {
505    /// Filter by order ID
506    pub order_id: Option<OrderId>,
507    /// Filter by invoice ID
508    pub invoice_id: Option<Uuid>,
509    /// Filter by customer ID
510    pub customer_id: Option<CustomerId>,
511    /// Filter by status
512    pub status: Option<PaymentTransactionStatus>,
513    /// Filter by payment method
514    pub payment_method: Option<PaymentMethodType>,
515    /// Filter by processor
516    pub processor: Option<String>,
517    /// Filter by currency
518    pub currency: Option<CurrencyCode>,
519    /// Filter by minimum amount
520    pub min_amount: Option<Decimal>,
521    /// Filter by maximum amount
522    pub max_amount: Option<Decimal>,
523    /// Filter by date range start
524    pub from_date: Option<DateTime<Utc>>,
525    /// Filter by date range end
526    pub to_date: Option<DateTime<Utc>>,
527    /// Maximum number of results
528    pub limit: Option<u32>,
529    /// Offset for pagination
530    pub offset: Option<u32>,
531}
532
533/// A refund for a payment
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct Refund {
536    /// Unique refund ID
537    pub id: Uuid,
538    /// Human-readable refund number
539    pub refund_number: String,
540    /// Associated payment ID
541    pub payment_id: PaymentId,
542    /// Refund status
543    pub status: RefundStatus,
544    /// Refund amount
545    pub amount: Decimal,
546    /// Currency code
547    pub currency: CurrencyCode,
548    /// Reason for refund
549    pub reason: Option<String>,
550    /// External refund ID from processor
551    pub external_id: Option<String>,
552    /// Idempotency key for safely retrying refund creation
553    pub idempotency_key: Option<String>,
554    /// Failure reason (if failed)
555    pub failure_reason: Option<String>,
556    /// Additional notes
557    pub notes: Option<String>,
558    /// When refund was completed
559    pub refunded_at: Option<DateTime<Utc>>,
560    /// When refund was created
561    pub created_at: DateTime<Utc>,
562    /// When refund was last updated
563    pub updated_at: DateTime<Utc>,
564}
565
566/// Input for creating a refund
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct CreateRefund {
569    /// Payment to refund
570    pub payment_id: PaymentId,
571    /// Refund amount (defaults to full payment amount)
572    pub amount: Option<Decimal>,
573    /// Reason for refund
574    pub reason: Option<String>,
575    /// External refund ID
576    pub external_id: Option<String>,
577    /// Idempotency key for safely retrying refund creation
578    pub idempotency_key: Option<String>,
579    /// Additional notes
580    pub notes: Option<String>,
581}
582
583impl Validate for CreateRefund {
584    /// Validate a refund create request.
585    ///
586    /// Requires a non-nil payment reference. The amount-specific rules
587    /// (positivity and not exceeding the remaining refundable balance) are
588    /// enforced against the live payment in [`Payment::validate_refund`], which
589    /// is where the refund's monetary semantics belong; this structural check
590    /// only guards the payment reference itself.
591    fn validate(&self) -> Result<()> {
592        ValidationBuilder::new().uuid_not_nil("payment_id", self.payment_id.into_uuid()).build()
593    }
594}
595
596/// A stored payment method for a customer
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct PaymentMethod {
599    /// Unique ID
600    pub id: Uuid,
601    /// Customer ID
602    pub customer_id: CustomerId,
603    /// Payment method type
604    pub method_type: PaymentMethodType,
605    /// Whether this is the default payment method
606    pub is_default: bool,
607    /// Card brand (if card)
608    pub card_brand: Option<CardBrand>,
609    /// Last 4 digits (if card)
610    pub card_last4: Option<String>,
611    /// Expiry month (if card)
612    pub card_exp_month: Option<i32>,
613    /// Expiry year (if card)
614    pub card_exp_year: Option<i32>,
615    /// Cardholder name
616    pub cardholder_name: Option<String>,
617    /// Bank name (if bank transfer)
618    pub bank_name: Option<String>,
619    /// Last 4 of account (if bank)
620    pub account_last4: Option<String>,
621    // =========================================================================
622    // Blockchain/Wallet Fields (for Stablecoin/Crypto payment methods)
623    // =========================================================================
624    /// Wallet address (for crypto/stablecoin payments)
625    pub wallet_address: Option<String>,
626    /// Preferred blockchain network
627    pub blockchain_network: Option<BlockchainNetwork>,
628    /// Preferred stablecoin type
629    pub stablecoin_type: Option<StablecoinType>,
630    // =========================================================================
631    /// External ID from payment processor
632    pub external_id: Option<String>,
633    /// Billing address
634    pub billing_address: Option<String>,
635    /// When the method was created
636    pub created_at: DateTime<Utc>,
637    /// When the method was last updated
638    pub updated_at: DateTime<Utc>,
639}
640
641/// Input for creating a payment method
642#[derive(Debug, Clone, Default, Serialize, Deserialize)]
643pub struct CreatePaymentMethod {
644    /// Customer ID
645    pub customer_id: CustomerId,
646    /// Payment method type
647    pub method_type: PaymentMethodType,
648    /// Set as default
649    pub is_default: Option<bool>,
650    /// Card brand
651    pub card_brand: Option<CardBrand>,
652    /// Last 4 digits
653    pub card_last4: Option<String>,
654    /// Expiry month
655    pub card_exp_month: Option<i32>,
656    /// Expiry year
657    pub card_exp_year: Option<i32>,
658    /// Cardholder name
659    pub cardholder_name: Option<String>,
660    /// Bank name
661    pub bank_name: Option<String>,
662    /// Account last 4
663    pub account_last4: Option<String>,
664    // =========================================================================
665    // Blockchain/Wallet Fields (for Stablecoin/Crypto payment methods)
666    // =========================================================================
667    /// Wallet address (for receiving payments)
668    pub wallet_address: Option<String>,
669    /// Preferred blockchain network
670    pub blockchain_network: Option<BlockchainNetwork>,
671    /// Preferred stablecoin type
672    pub stablecoin_type: Option<StablecoinType>,
673    // =========================================================================
674    /// External ID
675    pub external_id: Option<String>,
676    /// Billing address
677    pub billing_address: Option<String>,
678}
679
680/// Generate a unique payment number
681#[must_use]
682pub fn generate_payment_number() -> String {
683    generate_number("PAY")
684}
685
686/// Generate a unique refund number
687#[must_use]
688pub fn generate_refund_number() -> String {
689    generate_number("REF")
690}
691
692fn generate_number(prefix: &str) -> String {
693    let now = chrono::Utc::now();
694    let timestamp = now.timestamp_millis();
695    let entropy = uuid::Uuid::new_v4().simple().to_string();
696    format!("{prefix}-{timestamp}-{}", entropy[..12].to_ascii_uppercase())
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn payment_number_has_prefix_and_entropy_suffix() {
705        let value = generate_payment_number();
706        assert!(value.starts_with("PAY-"));
707        let parts: Vec<&str> = value.split('-').collect();
708        assert_eq!(parts.len(), 3);
709        assert_eq!(parts[2].len(), 12);
710    }
711
712    #[test]
713    fn refund_number_has_prefix_and_entropy_suffix() {
714        let value = generate_refund_number();
715        assert!(value.starts_with("REF-"));
716        let parts: Vec<&str> = value.split('-').collect();
717        assert_eq!(parts.len(), 3);
718        assert_eq!(parts[2].len(), 12);
719    }
720
721    #[test]
722    fn generated_numbers_are_not_equal() {
723        assert_ne!(generate_payment_number(), generate_payment_number());
724        assert_ne!(generate_refund_number(), generate_refund_number());
725    }
726
727    #[test]
728    fn payment_status_valid_transitions() {
729        use PaymentTransactionStatus::*;
730        // Pending transitions
731        assert!(Pending.can_transition_to(Processing));
732        assert!(Pending.can_transition_to(Cancelled));
733        assert!(Pending.can_transition_to(Failed));
734        // Processing transitions
735        assert!(Processing.can_transition_to(RequiresAction));
736        assert!(Processing.can_transition_to(Completed));
737        assert!(Processing.can_transition_to(Failed));
738        assert!(Processing.can_transition_to(Cancelled));
739        // RequiresAction transitions
740        assert!(RequiresAction.can_transition_to(Processing));
741        assert!(RequiresAction.can_transition_to(Completed));
742        // Completed transitions
743        assert!(Completed.can_transition_to(Refunded));
744        assert!(Completed.can_transition_to(PartiallyRefunded));
745        assert!(Completed.can_transition_to(Disputed));
746        // PartiallyRefunded transitions
747        assert!(PartiallyRefunded.can_transition_to(Refunded));
748        assert!(PartiallyRefunded.can_transition_to(Disputed));
749        // Disputed transitions
750        assert!(Disputed.can_transition_to(Completed));
751        assert!(Disputed.can_transition_to(Refunded));
752        assert!(Disputed.can_transition_to(Cancelled));
753    }
754
755    #[test]
756    fn payment_status_invalid_transitions() {
757        use PaymentTransactionStatus::*;
758        assert!(!Pending.can_transition_to(Completed));
759        assert!(!Pending.can_transition_to(Refunded));
760        assert!(!Completed.can_transition_to(Pending));
761        assert!(!Completed.can_transition_to(Processing));
762        assert!(!PartiallyRefunded.can_transition_to(Pending));
763    }
764
765    #[test]
766    fn payment_status_terminal_states() {
767        use PaymentTransactionStatus::*;
768        assert!(Failed.is_terminal());
769        assert!(Cancelled.is_terminal());
770        assert!(Refunded.is_terminal());
771        assert!(!Pending.is_terminal());
772        assert!(!Processing.is_terminal());
773        assert!(!Completed.is_terminal());
774        // Terminal states reject all transitions
775        assert!(!Failed.can_transition_to(Pending));
776        assert!(!Cancelled.can_transition_to(Processing));
777        assert!(!Refunded.can_transition_to(Completed));
778    }
779
780    #[test]
781    fn payment_status_self_transitions() {
782        use PaymentTransactionStatus::*;
783        assert!(Pending.can_transition_to(Pending));
784        assert!(Processing.can_transition_to(Processing));
785        assert!(Failed.can_transition_to(Failed));
786    }
787
788    #[test]
789    fn payment_status_is_successful() {
790        use PaymentTransactionStatus::*;
791        assert!(Completed.is_successful());
792        assert!(PartiallyRefunded.is_successful());
793        assert!(!Pending.is_successful());
794        assert!(!Failed.is_successful());
795        assert!(!Refunded.is_successful());
796    }
797
798    #[test]
799    fn refund_status_valid_transitions() {
800        use RefundStatus::*;
801        assert!(Pending.can_transition_to(Processing));
802        assert!(Pending.can_transition_to(Cancelled));
803        assert!(Pending.can_transition_to(Failed));
804        assert!(Processing.can_transition_to(Completed));
805        assert!(Processing.can_transition_to(Failed));
806    }
807
808    #[test]
809    fn refund_status_invalid_transitions() {
810        use RefundStatus::*;
811        assert!(!Pending.can_transition_to(Completed));
812        assert!(!Processing.can_transition_to(Cancelled));
813        assert!(!Completed.can_transition_to(Pending));
814        assert!(!Failed.can_transition_to(Processing));
815    }
816
817    #[test]
818    fn refund_status_terminal_states() {
819        use RefundStatus::*;
820        assert!(Completed.is_terminal());
821        assert!(Failed.is_terminal());
822        assert!(Cancelled.is_terminal());
823        assert!(!Pending.is_terminal());
824        assert!(!Processing.is_terminal());
825    }
826
827    #[test]
828    fn payment_status_is_refundable() {
829        use PaymentTransactionStatus::*;
830        // Only successfully captured funds are refundable.
831        assert!(Completed.is_refundable());
832        assert!(PartiallyRefunded.is_refundable());
833        // Everything else is not.
834        assert!(!Pending.is_refundable());
835        assert!(!Processing.is_refundable());
836        assert!(!RequiresAction.is_refundable());
837        assert!(!Failed.is_refundable());
838        assert!(!Cancelled.is_refundable());
839        assert!(!Refunded.is_refundable());
840        assert!(!Disputed.is_refundable());
841    }
842
843    /// Build a minimal `Payment` for refund-validation tests.
844    fn payment_for_refund(
845        status: PaymentTransactionStatus,
846        amount: Decimal,
847        amount_refunded: Decimal,
848    ) -> Payment {
849        let now = Utc::now();
850        Payment {
851            id: PaymentId::new(),
852            payment_number: generate_payment_number(),
853            order_id: None,
854            invoice_id: None,
855            customer_id: None,
856            status,
857            payment_method: PaymentMethodType::CreditCard,
858            amount,
859            currency: CurrencyCode::default(),
860            amount_refunded,
861            external_id: None,
862            idempotency_key: None,
863            processor: None,
864            card_brand: None,
865            card_last4: None,
866            card_exp_month: None,
867            card_exp_year: None,
868            blockchain_network: None,
869            stablecoin_type: None,
870            from_wallet_address: None,
871            to_wallet_address: None,
872            tx_hash: None,
873            block_number: None,
874            confirmations: None,
875            token_address: None,
876            ves_intent_id: None,
877            billing_email: None,
878            billing_name: None,
879            billing_address: None,
880            description: None,
881            failure_reason: None,
882            failure_code: None,
883            metadata: None,
884            paid_at: None,
885            version: 1,
886            created_at: now,
887            updated_at: now,
888        }
889    }
890
891    #[test]
892    fn validate_refund_resolves_full_remaining_when_amount_omitted() {
893        use rust_decimal_macros::dec;
894        let payment = payment_for_refund(PaymentTransactionStatus::Completed, dec!(100), dec!(40));
895        // Remaining refundable balance is 100 - 40 = 60.
896        assert_eq!(payment.validate_refund(None).expect("resolved"), dec!(60));
897    }
898
899    #[test]
900    fn validate_refund_rejects_non_refundable_status() {
901        use rust_decimal_macros::dec;
902        for status in [
903            PaymentTransactionStatus::Pending,
904            PaymentTransactionStatus::Processing,
905            PaymentTransactionStatus::RequiresAction,
906            PaymentTransactionStatus::Failed,
907            PaymentTransactionStatus::Cancelled,
908            PaymentTransactionStatus::Refunded,
909            PaymentTransactionStatus::Disputed,
910        ] {
911            let payment = payment_for_refund(status, dec!(100), dec!(0));
912            let err = payment
913                .validate_refund(Some(dec!(10)))
914                .expect_err("non-refundable status must be rejected");
915            assert!(matches!(err, crate::CommerceError::ValidationError(_)), "{status}: {err:?}");
916        }
917    }
918
919    #[test]
920    fn validate_refund_rejects_non_positive_amount() {
921        use rust_decimal_macros::dec;
922        let payment = payment_for_refund(PaymentTransactionStatus::Completed, dec!(100), dec!(0));
923        assert!(payment.validate_refund(Some(dec!(0))).is_err());
924        assert!(payment.validate_refund(Some(dec!(-1))).is_err());
925    }
926
927    #[test]
928    fn validate_refund_rejects_amount_exceeding_remaining() {
929        use rust_decimal_macros::dec;
930        let payment =
931            payment_for_refund(PaymentTransactionStatus::PartiallyRefunded, dec!(100), dec!(70));
932        // Only 30 remains refundable; 31 must be rejected, 30 must pass.
933        assert!(payment.validate_refund(Some(dec!(31))).is_err());
934        assert_eq!(payment.validate_refund(Some(dec!(30))).expect("ok"), dec!(30));
935    }
936
937    #[test]
938    fn create_payment_rejects_negative_amount() {
939        use crate::Validate;
940        use rust_decimal_macros::dec;
941        let input = CreatePayment { amount: dec!(-1), ..Default::default() };
942        let err = input.validate().expect_err("negative amount must be rejected");
943        assert!(
944            matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "amount")
945        );
946    }
947
948    #[test]
949    fn create_payment_accepts_zero_and_positive_amount() {
950        use crate::Validate;
951        use rust_decimal_macros::dec;
952        // A zero authorization is legitimate; only negatives are rejected.
953        assert!(CreatePayment { amount: dec!(0), ..Default::default() }.validate().is_ok());
954        assert!(CreatePayment { amount: dec!(99.99), ..Default::default() }.validate().is_ok());
955    }
956
957    #[test]
958    fn create_payment_rejects_malformed_billing_email() {
959        use crate::Validate;
960        use rust_decimal_macros::dec;
961        let input = CreatePayment {
962            amount: dec!(10),
963            billing_email: Some("not-an-email".to_string()),
964            ..Default::default()
965        };
966        let err = input.validate().expect_err("malformed billing email must be rejected");
967        assert!(
968            matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "billing_email")
969        );
970    }
971
972    #[test]
973    fn create_payment_accepts_valid_billing_email() {
974        use crate::Validate;
975        use rust_decimal_macros::dec;
976        let input = CreatePayment {
977            amount: dec!(10),
978            billing_email: Some("alice@example.com".to_string()),
979            ..Default::default()
980        };
981        assert!(input.validate().is_ok());
982    }
983
984    #[test]
985    fn create_refund_rejects_nil_payment_id() {
986        use crate::Validate;
987        use rust_decimal_macros::dec;
988        let input = CreateRefund {
989            payment_id: PaymentId::from_uuid(Uuid::nil()),
990            amount: Some(dec!(5)),
991            ..Default::default()
992        };
993        let err = input.validate().expect_err("a nil payment reference must be rejected");
994        assert!(
995            matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "payment_id")
996        );
997    }
998
999    #[test]
1000    fn create_refund_accepts_valid_payment_reference() {
1001        use crate::Validate;
1002        use rust_decimal_macros::dec;
1003        let id = PaymentId::new();
1004        // Structural validation passes regardless of amount; the amount's
1005        // positivity/balance rules are enforced by `Payment::validate_refund`.
1006        assert!(
1007            CreateRefund { payment_id: id, amount: None, ..Default::default() }.validate().is_ok()
1008        );
1009        assert!(
1010            CreateRefund { payment_id: id, amount: Some(dec!(25)), ..Default::default() }
1011                .validate()
1012                .is_ok()
1013        );
1014    }
1015}