Skip to main content

allsource_core/domain/entities/
paywall_article.rs

1use crate::{
2    domain::value_objects::{ArticleId, CreatorId, Money, TenantId},
3    error::Result,
4};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Article status in the paywall system
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub enum ArticleStatus {
11    /// Article is a draft, not yet published
12    Draft,
13    /// Article is active and available for purchase
14    #[default]
15    Active,
16    /// Article has been archived (no new purchases)
17    Archived,
18    /// Article has been deleted
19    Deleted,
20}
21
22/// Article statistics
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct ArticleStats {
25    /// Total number of purchases
26    pub total_purchases: u64,
27    /// Total revenue in cents
28    pub total_revenue_cents: u64,
29    /// Total number of unique readers
30    pub unique_readers: u64,
31    /// Average read duration in seconds
32    pub avg_read_duration_seconds: u64,
33    /// Average scroll depth percentage
34    pub avg_scroll_depth: u8,
35    /// Conversion rate (purchases / views * 100)
36    pub conversion_rate: f32,
37}
38
39impl ArticleStats {
40    pub fn new() -> Self {
41        Self::default()
42    }
43}
44
45/// Domain Entity: PaywallArticle
46///
47/// Represents a paywalled article in the system.
48/// Articles are created by creators and can be purchased by readers.
49///
50/// Domain Rules:
51/// - Article must have a valid ID (slug)
52/// - Price must be within allowed range ($0.10 - $10.00)
53/// - Article belongs to exactly one creator
54/// - Only active articles can be purchased
55/// - Article URL must be valid
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct PaywallArticle {
58    id: ArticleId,
59    tenant_id: TenantId,
60    creator_id: CreatorId,
61    title: String,
62    url: String,
63    price: Money,
64    description: Option<String>,
65    estimated_reading_time_minutes: Option<u16>,
66    preview_content: Option<String>,
67    status: ArticleStatus,
68    stats: ArticleStats,
69    created_at: DateTime<Utc>,
70    updated_at: DateTime<Utc>,
71    published_at: Option<DateTime<Utc>>,
72    metadata: serde_json::Value,
73}
74
75/// Minimum price: $0.10 (10 cents)
76const MIN_PRICE_CENTS: u64 = 10;
77/// Maximum price: $10.00 (1000 cents)
78const MAX_PRICE_CENTS: u64 = 1000;
79
80impl PaywallArticle {
81    /// Create a new paywall article with validation
82    pub fn new(
83        id: ArticleId,
84        tenant_id: TenantId,
85        creator_id: CreatorId,
86        title: String,
87        url: String,
88        price_cents: u64,
89    ) -> Result<Self> {
90        Self::validate_title(&title)?;
91        Self::validate_url(&url)?;
92        Self::validate_price(price_cents)?;
93
94        let now = Utc::now();
95        Ok(Self {
96            id,
97            tenant_id,
98            creator_id,
99            title,
100            url,
101            price: Money::usd_cents(price_cents),
102            description: None,
103            estimated_reading_time_minutes: None,
104            preview_content: None,
105            status: ArticleStatus::Active,
106            stats: ArticleStats::new(),
107            created_at: now,
108            updated_at: now,
109            published_at: Some(now),
110            metadata: serde_json::json!({}),
111        })
112    }
113
114    /// Create a draft article (not yet published)
115    pub fn new_draft(
116        id: ArticleId,
117        tenant_id: TenantId,
118        creator_id: CreatorId,
119        title: String,
120        url: String,
121        price_cents: u64,
122    ) -> Result<Self> {
123        Self::validate_title(&title)?;
124        Self::validate_url(&url)?;
125        Self::validate_price(price_cents)?;
126
127        let now = Utc::now();
128        Ok(Self {
129            id,
130            tenant_id,
131            creator_id,
132            title,
133            url,
134            price: Money::usd_cents(price_cents),
135            description: None,
136            estimated_reading_time_minutes: None,
137            preview_content: None,
138            status: ArticleStatus::Draft,
139            stats: ArticleStats::new(),
140            created_at: now,
141            updated_at: now,
142            published_at: None,
143            metadata: serde_json::json!({}),
144        })
145    }
146
147    /// Reconstruct article from storage (bypasses validation)
148    #[allow(clippy::too_many_arguments)]
149    pub fn reconstruct(
150        id: ArticleId,
151        tenant_id: TenantId,
152        creator_id: CreatorId,
153        title: String,
154        url: String,
155        price: Money,
156        description: Option<String>,
157        estimated_reading_time_minutes: Option<u16>,
158        preview_content: Option<String>,
159        status: ArticleStatus,
160        stats: ArticleStats,
161        created_at: DateTime<Utc>,
162        updated_at: DateTime<Utc>,
163        published_at: Option<DateTime<Utc>>,
164        metadata: serde_json::Value,
165    ) -> Self {
166        Self {
167            id,
168            tenant_id,
169            creator_id,
170            title,
171            url,
172            price,
173            description,
174            estimated_reading_time_minutes,
175            preview_content,
176            status,
177            stats,
178            created_at,
179            updated_at,
180            published_at,
181            metadata,
182        }
183    }
184
185    // Getters
186
187    pub fn id(&self) -> &ArticleId {
188        &self.id
189    }
190
191    pub fn tenant_id(&self) -> &TenantId {
192        &self.tenant_id
193    }
194
195    pub fn creator_id(&self) -> &CreatorId {
196        &self.creator_id
197    }
198
199    pub fn title(&self) -> &str {
200        &self.title
201    }
202
203    pub fn url(&self) -> &str {
204        &self.url
205    }
206
207    pub fn price(&self) -> &Money {
208        &self.price
209    }
210
211    pub fn price_cents(&self) -> u64 {
212        self.price.amount()
213    }
214
215    pub fn description(&self) -> Option<&str> {
216        self.description.as_deref()
217    }
218
219    pub fn estimated_reading_time_minutes(&self) -> Option<u16> {
220        self.estimated_reading_time_minutes
221    }
222
223    pub fn preview_content(&self) -> Option<&str> {
224        self.preview_content.as_deref()
225    }
226
227    pub fn status(&self) -> ArticleStatus {
228        self.status
229    }
230
231    pub fn stats(&self) -> &ArticleStats {
232        &self.stats
233    }
234
235    pub fn created_at(&self) -> DateTime<Utc> {
236        self.created_at
237    }
238
239    pub fn updated_at(&self) -> DateTime<Utc> {
240        self.updated_at
241    }
242
243    pub fn published_at(&self) -> Option<DateTime<Utc>> {
244        self.published_at
245    }
246
247    pub fn metadata(&self) -> &serde_json::Value {
248        &self.metadata
249    }
250
251    // Domain behavior methods
252
253    /// Check if article is active and can be purchased
254    pub fn is_purchasable(&self) -> bool {
255        self.status == ArticleStatus::Active
256    }
257
258    /// Check if this article belongs to a specific creator
259    pub fn belongs_to(&self, creator_id: &CreatorId) -> bool {
260        &self.creator_id == creator_id
261    }
262
263    /// Update the title
264    pub fn update_title(&mut self, title: String) -> Result<()> {
265        Self::validate_title(&title)?;
266        self.title = title;
267        self.updated_at = Utc::now();
268        Ok(())
269    }
270
271    /// Update the URL
272    pub fn update_url(&mut self, url: String) -> Result<()> {
273        Self::validate_url(&url)?;
274        self.url = url;
275        self.updated_at = Utc::now();
276        Ok(())
277    }
278
279    /// Update the price
280    pub fn update_price(&mut self, price_cents: u64) -> Result<()> {
281        Self::validate_price(price_cents)?;
282        self.price = Money::usd_cents(price_cents);
283        self.updated_at = Utc::now();
284        Ok(())
285    }
286
287    /// Update the description
288    pub fn update_description(&mut self, description: Option<String>) {
289        self.description = description;
290        self.updated_at = Utc::now();
291    }
292
293    /// Update the estimated reading time
294    pub fn update_reading_time(&mut self, minutes: Option<u16>) {
295        self.estimated_reading_time_minutes = minutes;
296        self.updated_at = Utc::now();
297    }
298
299    /// Update the preview content
300    pub fn update_preview(&mut self, preview: Option<String>) -> Result<()> {
301        if let Some(ref p) = preview
302            && p.len() > 1000
303        {
304            return Err(crate::error::AllSourceError::ValidationError(
305                "Preview content cannot exceed 1000 characters".to_string(),
306            ));
307        }
308        self.preview_content = preview;
309        self.updated_at = Utc::now();
310        Ok(())
311    }
312
313    /// Publish a draft article
314    pub fn publish(&mut self) -> Result<()> {
315        if self.status != ArticleStatus::Draft {
316            return Err(crate::error::AllSourceError::ValidationError(format!(
317                "Can only publish draft articles, current status: {:?}",
318                self.status
319            )));
320        }
321
322        self.status = ArticleStatus::Active;
323        self.published_at = Some(Utc::now());
324        self.updated_at = Utc::now();
325        Ok(())
326    }
327
328    /// Archive the article (no new purchases allowed)
329    pub fn archive(&mut self) {
330        self.status = ArticleStatus::Archived;
331        self.updated_at = Utc::now();
332    }
333
334    /// Restore an archived article
335    pub fn restore(&mut self) -> Result<()> {
336        if self.status != ArticleStatus::Archived {
337            return Err(crate::error::AllSourceError::ValidationError(
338                "Can only restore archived articles".to_string(),
339            ));
340        }
341
342        self.status = ArticleStatus::Active;
343        self.updated_at = Utc::now();
344        Ok(())
345    }
346
347    /// Mark the article as deleted
348    pub fn delete(&mut self) {
349        self.status = ArticleStatus::Deleted;
350        self.updated_at = Utc::now();
351    }
352
353    /// Record a purchase for this article
354    pub fn record_purchase(&mut self, amount_cents: u64) {
355        self.stats.total_purchases += 1;
356        self.stats.total_revenue_cents += amount_cents;
357        self.updated_at = Utc::now();
358    }
359
360    /// Record a unique reader
361    pub fn record_reader(&mut self) {
362        self.stats.unique_readers += 1;
363        self.updated_at = Utc::now();
364    }
365
366    /// Update reading analytics
367    pub fn update_reading_analytics(&mut self, duration_seconds: u64, scroll_depth: u8) {
368        // Simple moving average approximation
369        let total = self.stats.total_purchases.max(1);
370        self.stats.avg_read_duration_seconds =
371            ((self.stats.avg_read_duration_seconds * (total - 1)) + duration_seconds) / total;
372        self.stats.avg_scroll_depth = (((u64::from(self.stats.avg_scroll_depth) * (total - 1))
373            + u64::from(scroll_depth))
374            / total) as u8;
375        self.updated_at = Utc::now();
376    }
377
378    /// Update conversion rate
379    pub fn update_conversion_rate(&mut self, views: u64) {
380        if views > 0 {
381            self.stats.conversion_rate = (self.stats.total_purchases as f32 / views as f32) * 100.0;
382        }
383        self.updated_at = Utc::now();
384    }
385
386    /// Update metadata
387    pub fn update_metadata(&mut self, metadata: serde_json::Value) {
388        self.metadata = metadata;
389        self.updated_at = Utc::now();
390    }
391
392    // Validation
393
394    fn validate_title(title: &str) -> Result<()> {
395        if title.is_empty() {
396            return Err(crate::error::AllSourceError::InvalidInput(
397                "Article title cannot be empty".to_string(),
398            ));
399        }
400
401        if title.len() > 500 {
402            return Err(crate::error::AllSourceError::InvalidInput(
403                "Article title cannot exceed 500 characters".to_string(),
404            ));
405        }
406
407        Ok(())
408    }
409
410    fn validate_url(url: &str) -> Result<()> {
411        if url.is_empty() {
412            return Err(crate::error::AllSourceError::InvalidInput(
413                "Article URL cannot be empty".to_string(),
414            ));
415        }
416
417        if !url.starts_with("http://") && !url.starts_with("https://") {
418            return Err(crate::error::AllSourceError::InvalidInput(
419                "Article URL must start with http:// or https://".to_string(),
420            ));
421        }
422
423        if url.len() > 2048 {
424            return Err(crate::error::AllSourceError::InvalidInput(
425                "Article URL cannot exceed 2048 characters".to_string(),
426            ));
427        }
428
429        Ok(())
430    }
431
432    fn validate_price(price_cents: u64) -> Result<()> {
433        if price_cents < MIN_PRICE_CENTS {
434            return Err(crate::error::AllSourceError::ValidationError(format!(
435                "Price must be at least ${:.2}",
436                MIN_PRICE_CENTS as f64 / 100.0
437            )));
438        }
439
440        if price_cents > MAX_PRICE_CENTS {
441            return Err(crate::error::AllSourceError::ValidationError(format!(
442                "Price cannot exceed ${:.2}",
443                MAX_PRICE_CENTS as f64 / 100.0
444            )));
445        }
446
447        Ok(())
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn test_tenant_id() -> TenantId {
456        TenantId::new("test-tenant".to_string()).unwrap()
457    }
458
459    fn test_article_id() -> ArticleId {
460        ArticleId::new("test-article".to_string()).unwrap()
461    }
462
463    fn test_creator_id() -> CreatorId {
464        CreatorId::new()
465    }
466
467    #[test]
468    fn test_create_article() {
469        let article = PaywallArticle::new(
470            test_article_id(),
471            test_tenant_id(),
472            test_creator_id(),
473            "My Awesome Article".to_string(),
474            "https://blog.example.com/article".to_string(),
475            50, // $0.50
476        );
477
478        assert!(article.is_ok());
479        let article = article.unwrap();
480        assert_eq!(article.title(), "My Awesome Article");
481        assert_eq!(article.price_cents(), 50);
482        assert_eq!(article.status(), ArticleStatus::Active);
483        assert!(article.is_purchasable());
484    }
485
486    #[test]
487    fn test_create_draft_article() {
488        let article = PaywallArticle::new_draft(
489            test_article_id(),
490            test_tenant_id(),
491            test_creator_id(),
492            "Draft Article".to_string(),
493            "https://blog.example.com/draft".to_string(),
494            100,
495        );
496
497        assert!(article.is_ok());
498        let article = article.unwrap();
499        assert_eq!(article.status(), ArticleStatus::Draft);
500        assert!(!article.is_purchasable());
501        assert!(article.published_at().is_none());
502    }
503
504    #[test]
505    fn test_reject_empty_title() {
506        let result = PaywallArticle::new(
507            test_article_id(),
508            test_tenant_id(),
509            test_creator_id(),
510            String::new(),
511            "https://blog.example.com/article".to_string(),
512            50,
513        );
514
515        assert!(result.is_err());
516    }
517
518    #[test]
519    fn test_reject_invalid_url() {
520        let result = PaywallArticle::new(
521            test_article_id(),
522            test_tenant_id(),
523            test_creator_id(),
524            "Title".to_string(),
525            "not-a-url".to_string(),
526            50,
527        );
528
529        assert!(result.is_err());
530    }
531
532    #[test]
533    fn test_reject_price_too_low() {
534        let result = PaywallArticle::new(
535            test_article_id(),
536            test_tenant_id(),
537            test_creator_id(),
538            "Title".to_string(),
539            "https://example.com".to_string(),
540            5, // $0.05 - below minimum
541        );
542
543        assert!(result.is_err());
544    }
545
546    #[test]
547    fn test_reject_price_too_high() {
548        let result = PaywallArticle::new(
549            test_article_id(),
550            test_tenant_id(),
551            test_creator_id(),
552            "Title".to_string(),
553            "https://example.com".to_string(),
554            1500, // $15.00 - above maximum
555        );
556
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn test_publish_draft() {
562        let mut article = PaywallArticle::new_draft(
563            test_article_id(),
564            test_tenant_id(),
565            test_creator_id(),
566            "Draft Article".to_string(),
567            "https://example.com".to_string(),
568            50,
569        )
570        .unwrap();
571
572        assert!(!article.is_purchasable());
573
574        let result = article.publish();
575        assert!(result.is_ok());
576        assert!(article.is_purchasable());
577        assert!(article.published_at().is_some());
578    }
579
580    #[test]
581    fn test_cannot_publish_active_article() {
582        let mut article = PaywallArticle::new(
583            test_article_id(),
584            test_tenant_id(),
585            test_creator_id(),
586            "Active Article".to_string(),
587            "https://example.com".to_string(),
588            50,
589        )
590        .unwrap();
591
592        let result = article.publish();
593        assert!(result.is_err());
594    }
595
596    #[test]
597    fn test_archive_and_restore() {
598        let mut article = PaywallArticle::new(
599            test_article_id(),
600            test_tenant_id(),
601            test_creator_id(),
602            "Article".to_string(),
603            "https://example.com".to_string(),
604            50,
605        )
606        .unwrap();
607
608        assert!(article.is_purchasable());
609
610        article.archive();
611        assert_eq!(article.status(), ArticleStatus::Archived);
612        assert!(!article.is_purchasable());
613
614        article.restore().unwrap();
615        assert_eq!(article.status(), ArticleStatus::Active);
616        assert!(article.is_purchasable());
617    }
618
619    #[test]
620    fn test_record_purchase() {
621        let mut article = PaywallArticle::new(
622            test_article_id(),
623            test_tenant_id(),
624            test_creator_id(),
625            "Article".to_string(),
626            "https://example.com".to_string(),
627            50,
628        )
629        .unwrap();
630
631        assert_eq!(article.stats().total_purchases, 0);
632        assert_eq!(article.stats().total_revenue_cents, 0);
633
634        article.record_purchase(50);
635        assert_eq!(article.stats().total_purchases, 1);
636        assert_eq!(article.stats().total_revenue_cents, 50);
637
638        article.record_purchase(50);
639        assert_eq!(article.stats().total_purchases, 2);
640        assert_eq!(article.stats().total_revenue_cents, 100);
641    }
642
643    #[test]
644    fn test_update_price() {
645        let mut article = PaywallArticle::new(
646            test_article_id(),
647            test_tenant_id(),
648            test_creator_id(),
649            "Article".to_string(),
650            "https://example.com".to_string(),
651            50,
652        )
653        .unwrap();
654
655        assert_eq!(article.price_cents(), 50);
656
657        article.update_price(100).unwrap();
658        assert_eq!(article.price_cents(), 100);
659
660        // Should fail for invalid price
661        assert!(article.update_price(5).is_err());
662    }
663
664    #[test]
665    fn test_belongs_to() {
666        let creator_id = test_creator_id();
667        let other_creator_id = CreatorId::new();
668
669        let article = PaywallArticle::new(
670            test_article_id(),
671            test_tenant_id(),
672            creator_id,
673            "Article".to_string(),
674            "https://example.com".to_string(),
675            50,
676        )
677        .unwrap();
678
679        assert!(article.belongs_to(&creator_id));
680        assert!(!article.belongs_to(&other_creator_id));
681    }
682
683    #[test]
684    fn test_serde_serialization() {
685        let article = PaywallArticle::new(
686            test_article_id(),
687            test_tenant_id(),
688            test_creator_id(),
689            "Test Article".to_string(),
690            "https://example.com".to_string(),
691            50,
692        )
693        .unwrap();
694
695        let json = serde_json::to_string(&article);
696        assert!(json.is_ok());
697
698        let deserialized: PaywallArticle = serde_json::from_str(&json.unwrap()).unwrap();
699        assert_eq!(deserialized.title(), "Test Article");
700    }
701}