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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum Blockchain {
11 #[default]
13 Solana,
14 Base,
16 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
32pub enum TransactionStatus {
33 #[default]
35 Pending,
36 Confirmed,
38 Failed,
40 Refunded,
42 Disputed,
44}
45
46#[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 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 #[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 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 pub fn is_confirmed(&self) -> bool {
229 self.status == TransactionStatus::Confirmed
230 }
231
232 pub fn is_pending(&self) -> bool {
234 self.status == TransactionStatus::Pending
235 }
236
237 pub fn grants_access(&self) -> bool {
239 self.status == TransactionStatus::Confirmed
240 }
241
242 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 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 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 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 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 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 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 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), 7, 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); assert_eq!(transaction.creator_amount_cents(), 93); 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 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}