Skip to main content

allsource_core/domain/entities/
transaction.rs

1use crate::domain::value_objects::{
2    ArticleId, CreatorId, Money, TenantId, TransactionId, WalletAddress,
3};
4use crate::error::Result;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Blockchain network for the transaction
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum Blockchain {
11    /// Solana mainnet
12    Solana,
13    /// Base (Coinbase L2)
14    Base,
15    /// Polygon
16    Polygon,
17}
18
19impl Default for Blockchain {
20    fn default() -> Self {
21        Self::Solana
22    }
23}
24
25impl std::fmt::Display for Blockchain {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Blockchain::Solana => write!(f, "solana"),
29            Blockchain::Base => write!(f, "base"),
30            Blockchain::Polygon => write!(f, "polygon"),
31        }
32    }
33}
34
35/// Transaction status
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37pub enum TransactionStatus {
38    /// Transaction is pending verification
39    Pending,
40    /// Transaction has been verified on-chain
41    Confirmed,
42    /// Transaction failed verification
43    Failed,
44    /// Transaction has been refunded
45    Refunded,
46    /// Transaction is disputed
47    Disputed,
48}
49
50impl Default for TransactionStatus {
51    fn default() -> Self {
52        Self::Pending
53    }
54}
55
56/// Domain Entity: Transaction
57///
58/// Represents a payment transaction for article access in the paywall system.
59/// Transactions are immutable once confirmed - any changes create new events.
60///
61/// Domain Rules:
62/// - Transaction must have a valid blockchain signature
63/// - Amount must be positive and match article price
64/// - Reader wallet must be valid
65/// - Only pending transactions can be confirmed or failed
66/// - Only confirmed transactions can be refunded or disputed
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Transaction {
69    id: TransactionId,
70    tenant_id: TenantId,
71    article_id: ArticleId,
72    creator_id: CreatorId,
73    reader_wallet: WalletAddress,
74    amount: Money,
75    platform_fee: Money,
76    creator_amount: Money,
77    blockchain: Blockchain,
78    tx_signature: String,
79    status: TransactionStatus,
80    created_at: DateTime<Utc>,
81    confirmed_at: Option<DateTime<Utc>>,
82    refunded_at: Option<DateTime<Utc>>,
83    metadata: serde_json::Value,
84}
85
86impl Transaction {
87    /// Create a new pending transaction
88    pub fn new(
89        tenant_id: TenantId,
90        article_id: ArticleId,
91        creator_id: CreatorId,
92        reader_wallet: WalletAddress,
93        amount: Money,
94        platform_fee_percentage: u64,
95        blockchain: Blockchain,
96        tx_signature: String,
97    ) -> Result<Self> {
98        Self::validate_amount(&amount)?;
99        Self::validate_signature(&tx_signature)?;
100
101        let platform_fee = amount.percentage(platform_fee_percentage);
102        let creator_amount = (amount - platform_fee)?;
103
104        Ok(Self {
105            id: TransactionId::new(),
106            tenant_id,
107            article_id,
108            creator_id,
109            reader_wallet,
110            amount,
111            platform_fee,
112            creator_amount,
113            blockchain,
114            tx_signature,
115            status: TransactionStatus::Pending,
116            created_at: Utc::now(),
117            confirmed_at: None,
118            refunded_at: None,
119            metadata: serde_json::json!({}),
120        })
121    }
122
123    /// Reconstruct transaction from storage (bypasses validation)
124    #[allow(clippy::too_many_arguments)]
125    pub fn reconstruct(
126        id: TransactionId,
127        tenant_id: TenantId,
128        article_id: ArticleId,
129        creator_id: CreatorId,
130        reader_wallet: WalletAddress,
131        amount: Money,
132        platform_fee: Money,
133        creator_amount: Money,
134        blockchain: Blockchain,
135        tx_signature: String,
136        status: TransactionStatus,
137        created_at: DateTime<Utc>,
138        confirmed_at: Option<DateTime<Utc>>,
139        refunded_at: Option<DateTime<Utc>>,
140        metadata: serde_json::Value,
141    ) -> Self {
142        Self {
143            id,
144            tenant_id,
145            article_id,
146            creator_id,
147            reader_wallet,
148            amount,
149            platform_fee,
150            creator_amount,
151            blockchain,
152            tx_signature,
153            status,
154            created_at,
155            confirmed_at,
156            refunded_at,
157            metadata,
158        }
159    }
160
161    // Getters
162
163    pub fn id(&self) -> &TransactionId {
164        &self.id
165    }
166
167    pub fn tenant_id(&self) -> &TenantId {
168        &self.tenant_id
169    }
170
171    pub fn article_id(&self) -> &ArticleId {
172        &self.article_id
173    }
174
175    pub fn creator_id(&self) -> &CreatorId {
176        &self.creator_id
177    }
178
179    pub fn reader_wallet(&self) -> &WalletAddress {
180        &self.reader_wallet
181    }
182
183    pub fn amount(&self) -> &Money {
184        &self.amount
185    }
186
187    pub fn amount_cents(&self) -> u64 {
188        self.amount.amount()
189    }
190
191    pub fn platform_fee(&self) -> &Money {
192        &self.platform_fee
193    }
194
195    pub fn platform_fee_cents(&self) -> u64 {
196        self.platform_fee.amount()
197    }
198
199    pub fn creator_amount(&self) -> &Money {
200        &self.creator_amount
201    }
202
203    pub fn creator_amount_cents(&self) -> u64 {
204        self.creator_amount.amount()
205    }
206
207    pub fn blockchain(&self) -> Blockchain {
208        self.blockchain
209    }
210
211    pub fn tx_signature(&self) -> &str {
212        &self.tx_signature
213    }
214
215    pub fn status(&self) -> TransactionStatus {
216        self.status
217    }
218
219    pub fn created_at(&self) -> DateTime<Utc> {
220        self.created_at
221    }
222
223    pub fn confirmed_at(&self) -> Option<DateTime<Utc>> {
224        self.confirmed_at
225    }
226
227    pub fn refunded_at(&self) -> Option<DateTime<Utc>> {
228        self.refunded_at
229    }
230
231    pub fn metadata(&self) -> &serde_json::Value {
232        &self.metadata
233    }
234
235    // Domain behavior methods
236
237    /// Check if transaction is confirmed
238    pub fn is_confirmed(&self) -> bool {
239        self.status == TransactionStatus::Confirmed
240    }
241
242    /// Check if transaction is pending
243    pub fn is_pending(&self) -> bool {
244        self.status == TransactionStatus::Pending
245    }
246
247    /// Check if transaction can grant access (confirmed and not refunded)
248    pub fn grants_access(&self) -> bool {
249        self.status == TransactionStatus::Confirmed
250    }
251
252    /// Confirm the transaction (payment verified on-chain)
253    pub fn confirm(&mut self) -> Result<()> {
254        if self.status != TransactionStatus::Pending {
255            return Err(crate::error::AllSourceError::ValidationError(format!(
256                "Cannot confirm transaction with status {:?}",
257                self.status
258            )));
259        }
260
261        self.status = TransactionStatus::Confirmed;
262        self.confirmed_at = Some(Utc::now());
263        Ok(())
264    }
265
266    /// Mark transaction as failed
267    pub fn fail(&mut self, reason: &str) -> Result<()> {
268        if self.status != TransactionStatus::Pending {
269            return Err(crate::error::AllSourceError::ValidationError(format!(
270                "Cannot fail transaction with status {:?}",
271                self.status
272            )));
273        }
274
275        self.status = TransactionStatus::Failed;
276        self.metadata["failure_reason"] = serde_json::json!(reason);
277        Ok(())
278    }
279
280    /// Refund the transaction
281    pub fn refund(&mut self, refund_tx_signature: &str) -> Result<()> {
282        if self.status != TransactionStatus::Confirmed {
283            return Err(crate::error::AllSourceError::ValidationError(
284                "Can only refund confirmed transactions".to_string(),
285            ));
286        }
287
288        self.status = TransactionStatus::Refunded;
289        self.refunded_at = Some(Utc::now());
290        self.metadata["refund_tx_signature"] = serde_json::json!(refund_tx_signature);
291        Ok(())
292    }
293
294    /// Mark transaction as disputed
295    pub fn dispute(&mut self, reason: &str) -> Result<()> {
296        if self.status != TransactionStatus::Confirmed {
297            return Err(crate::error::AllSourceError::ValidationError(
298                "Can only dispute confirmed transactions".to_string(),
299            ));
300        }
301
302        self.status = TransactionStatus::Disputed;
303        self.metadata["dispute_reason"] = serde_json::json!(reason);
304        self.metadata["disputed_at"] = serde_json::json!(Utc::now().to_rfc3339());
305        Ok(())
306    }
307
308    /// Resolve a dispute (back to confirmed)
309    pub fn resolve_dispute(&mut self, resolution: &str) -> Result<()> {
310        if self.status != TransactionStatus::Disputed {
311            return Err(crate::error::AllSourceError::ValidationError(
312                "Can only resolve disputed transactions".to_string(),
313            ));
314        }
315
316        self.status = TransactionStatus::Confirmed;
317        self.metadata["dispute_resolution"] = serde_json::json!(resolution);
318        self.metadata["resolved_at"] = serde_json::json!(Utc::now().to_rfc3339());
319        Ok(())
320    }
321
322    /// Get explorer URL for the transaction
323    pub fn explorer_url(&self) -> String {
324        match self.blockchain {
325            Blockchain::Solana => {
326                format!("https://solscan.io/tx/{}", self.tx_signature)
327            }
328            Blockchain::Base => {
329                format!("https://basescan.org/tx/{}", self.tx_signature)
330            }
331            Blockchain::Polygon => {
332                format!("https://polygonscan.com/tx/{}", self.tx_signature)
333            }
334        }
335    }
336
337    // Validation
338
339    fn validate_amount(amount: &Money) -> Result<()> {
340        if amount.is_zero() {
341            return Err(crate::error::AllSourceError::ValidationError(
342                "Transaction amount must be positive".to_string(),
343            ));
344        }
345        Ok(())
346    }
347
348    fn validate_signature(signature: &str) -> Result<()> {
349        if signature.is_empty() {
350            return Err(crate::error::AllSourceError::InvalidInput(
351                "Transaction signature cannot be empty".to_string(),
352            ));
353        }
354
355        // Solana signatures are 88 characters base58
356        // Allow some flexibility for different blockchains
357        if signature.len() < 40 || signature.len() > 128 {
358            return Err(crate::error::AllSourceError::InvalidInput(format!(
359                "Invalid transaction signature length: {}",
360                signature.len()
361            )));
362        }
363
364        Ok(())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::domain::value_objects::Currency;
372
373    const VALID_WALLET: &str = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
374    const VALID_SIGNATURE: &str =
375        "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9g8eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9g";
376
377    fn test_tenant_id() -> TenantId {
378        TenantId::new("test-tenant".to_string()).unwrap()
379    }
380
381    fn test_article_id() -> ArticleId {
382        ArticleId::new("test-article".to_string()).unwrap()
383    }
384
385    fn test_creator_id() -> CreatorId {
386        CreatorId::new()
387    }
388
389    fn test_wallet() -> WalletAddress {
390        WalletAddress::new(VALID_WALLET.to_string()).unwrap()
391    }
392
393    #[test]
394    fn test_create_transaction() {
395        let transaction = Transaction::new(
396            test_tenant_id(),
397            test_article_id(),
398            test_creator_id(),
399            test_wallet(),
400            Money::usd_cents(100), // $1.00
401            7,                     // 7% fee
402            Blockchain::Solana,
403            VALID_SIGNATURE.to_string(),
404        );
405
406        assert!(transaction.is_ok());
407        let transaction = transaction.unwrap();
408
409        assert_eq!(transaction.amount_cents(), 100);
410        assert_eq!(transaction.platform_fee_cents(), 7); // 7%
411        assert_eq!(transaction.creator_amount_cents(), 93); // 100 - 7
412        assert_eq!(transaction.status(), TransactionStatus::Pending);
413        assert!(transaction.is_pending());
414    }
415
416    #[test]
417    fn test_reject_zero_amount() {
418        let result = Transaction::new(
419            test_tenant_id(),
420            test_article_id(),
421            test_creator_id(),
422            test_wallet(),
423            Money::zero(Currency::USD),
424            7,
425            Blockchain::Solana,
426            VALID_SIGNATURE.to_string(),
427        );
428
429        assert!(result.is_err());
430    }
431
432    #[test]
433    fn test_reject_empty_signature() {
434        let result = Transaction::new(
435            test_tenant_id(),
436            test_article_id(),
437            test_creator_id(),
438            test_wallet(),
439            Money::usd_cents(100),
440            7,
441            Blockchain::Solana,
442            "".to_string(),
443        );
444
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_confirm_transaction() {
450        let mut transaction = Transaction::new(
451            test_tenant_id(),
452            test_article_id(),
453            test_creator_id(),
454            test_wallet(),
455            Money::usd_cents(100),
456            7,
457            Blockchain::Solana,
458            VALID_SIGNATURE.to_string(),
459        )
460        .unwrap();
461
462        assert!(transaction.is_pending());
463        assert!(!transaction.grants_access());
464
465        let result = transaction.confirm();
466        assert!(result.is_ok());
467        assert!(transaction.is_confirmed());
468        assert!(transaction.grants_access());
469        assert!(transaction.confirmed_at().is_some());
470    }
471
472    #[test]
473    fn test_cannot_confirm_confirmed() {
474        let mut transaction = Transaction::new(
475            test_tenant_id(),
476            test_article_id(),
477            test_creator_id(),
478            test_wallet(),
479            Money::usd_cents(100),
480            7,
481            Blockchain::Solana,
482            VALID_SIGNATURE.to_string(),
483        )
484        .unwrap();
485
486        transaction.confirm().unwrap();
487        let result = transaction.confirm();
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn test_fail_transaction() {
493        let mut transaction = Transaction::new(
494            test_tenant_id(),
495            test_article_id(),
496            test_creator_id(),
497            test_wallet(),
498            Money::usd_cents(100),
499            7,
500            Blockchain::Solana,
501            VALID_SIGNATURE.to_string(),
502        )
503        .unwrap();
504
505        let result = transaction.fail("Insufficient funds");
506        assert!(result.is_ok());
507        assert_eq!(transaction.status(), TransactionStatus::Failed);
508    }
509
510    #[test]
511    fn test_refund_transaction() {
512        let mut transaction = Transaction::new(
513            test_tenant_id(),
514            test_article_id(),
515            test_creator_id(),
516            test_wallet(),
517            Money::usd_cents(100),
518            7,
519            Blockchain::Solana,
520            VALID_SIGNATURE.to_string(),
521        )
522        .unwrap();
523
524        // Must confirm first
525        transaction.confirm().unwrap();
526
527        let result = transaction.refund("refund_tx_sig_12345678901234567890123456789012345678901234");
528        assert!(result.is_ok());
529        assert_eq!(transaction.status(), TransactionStatus::Refunded);
530        assert!(transaction.refunded_at().is_some());
531        assert!(!transaction.grants_access());
532    }
533
534    #[test]
535    fn test_cannot_refund_pending() {
536        let mut transaction = Transaction::new(
537            test_tenant_id(),
538            test_article_id(),
539            test_creator_id(),
540            test_wallet(),
541            Money::usd_cents(100),
542            7,
543            Blockchain::Solana,
544            VALID_SIGNATURE.to_string(),
545        )
546        .unwrap();
547
548        let result = transaction.refund("refund_sig");
549        assert!(result.is_err());
550    }
551
552    #[test]
553    fn test_dispute_transaction() {
554        let mut transaction = Transaction::new(
555            test_tenant_id(),
556            test_article_id(),
557            test_creator_id(),
558            test_wallet(),
559            Money::usd_cents(100),
560            7,
561            Blockchain::Solana,
562            VALID_SIGNATURE.to_string(),
563        )
564        .unwrap();
565
566        transaction.confirm().unwrap();
567        let result = transaction.dispute("Content not delivered");
568        assert!(result.is_ok());
569        assert_eq!(transaction.status(), TransactionStatus::Disputed);
570    }
571
572    #[test]
573    fn test_resolve_dispute() {
574        let mut transaction = Transaction::new(
575            test_tenant_id(),
576            test_article_id(),
577            test_creator_id(),
578            test_wallet(),
579            Money::usd_cents(100),
580            7,
581            Blockchain::Solana,
582            VALID_SIGNATURE.to_string(),
583        )
584        .unwrap();
585
586        transaction.confirm().unwrap();
587        transaction.dispute("Content not delivered").unwrap();
588
589        let result = transaction.resolve_dispute("Content was delivered, access restored");
590        assert!(result.is_ok());
591        assert_eq!(transaction.status(), TransactionStatus::Confirmed);
592    }
593
594    #[test]
595    fn test_explorer_url() {
596        let transaction = Transaction::new(
597            test_tenant_id(),
598            test_article_id(),
599            test_creator_id(),
600            test_wallet(),
601            Money::usd_cents(100),
602            7,
603            Blockchain::Solana,
604            VALID_SIGNATURE.to_string(),
605        )
606        .unwrap();
607
608        let url = transaction.explorer_url();
609        assert!(url.contains("solscan.io"));
610        assert!(url.contains(VALID_SIGNATURE));
611    }
612
613    #[test]
614    fn test_serde_serialization() {
615        let transaction = Transaction::new(
616            test_tenant_id(),
617            test_article_id(),
618            test_creator_id(),
619            test_wallet(),
620            Money::usd_cents(100),
621            7,
622            Blockchain::Solana,
623            VALID_SIGNATURE.to_string(),
624        )
625        .unwrap();
626
627        let json = serde_json::to_string(&transaction);
628        assert!(json.is_ok());
629
630        let deserialized: Transaction = serde_json::from_str(&json.unwrap()).unwrap();
631        assert_eq!(deserialized.amount_cents(), 100);
632    }
633}