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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum Blockchain {
11 Solana,
13 Base,
15 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37pub enum TransactionStatus {
38 Pending,
40 Confirmed,
42 Failed,
44 Refunded,
46 Disputed,
48}
49
50impl Default for TransactionStatus {
51 fn default() -> Self {
52 Self::Pending
53 }
54}
55
56#[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 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 #[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 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 pub fn is_confirmed(&self) -> bool {
239 self.status == TransactionStatus::Confirmed
240 }
241
242 pub fn is_pending(&self) -> bool {
244 self.status == TransactionStatus::Pending
245 }
246
247 pub fn grants_access(&self) -> bool {
249 self.status == TransactionStatus::Confirmed
250 }
251
252 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 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 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 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 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 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 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 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), 7, 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); assert_eq!(transaction.creator_amount_cents(), 93); 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 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}