Skip to main content

allsource_core/domain/entities/
transaction.rs

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