Skip to main content

stateset_core/models/
subscription.rs

1//! Subscription domain models
2//!
3//! Comprehensive subscription management supporting:
4//! - Subscription plans with flexible billing intervals
5//! - Customer subscriptions with lifecycle management
6//! - Billing cycles and payment tracking
7//! - Pause, resume, skip, and cancel functionality
8//! - Trial periods and promotional pricing
9//! - Product swapping and quantity changes
10
11use chrono::{DateTime, Utc};
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14use stateset_primitives::{CurrencyCode, CustomerId, OrderId, ProductId, SubscriptionId};
15use strum::{Display, EnumString};
16use uuid::Uuid;
17
18use super::Address;
19
20// ============================================================================
21// Subscription Enums
22// ============================================================================
23
24/// Billing interval for subscriptions
25#[derive(
26    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
27)]
28#[serde(rename_all = "snake_case")]
29#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
30#[non_exhaustive]
31pub enum BillingInterval {
32    /// Weekly billing
33    Weekly,
34    /// Every two weeks
35    #[strum(serialize = "biweekly", serialize = "bi_weekly", serialize = "bi-weekly")]
36    Biweekly,
37    /// Monthly billing
38    #[default]
39    Monthly,
40    /// Every two months
41    #[strum(serialize = "bimonthly", serialize = "bi_monthly", serialize = "bi-monthly")]
42    Bimonthly,
43    /// Quarterly (every 3 months)
44    Quarterly,
45    /// Every 6 months
46    #[strum(serialize = "semiannual", serialize = "semi_annual", serialize = "semi-annual")]
47    Semiannual,
48    /// Annual billing
49    #[strum(serialize = "annual", serialize = "yearly")]
50    Annual,
51    /// Custom interval in days
52    Custom,
53}
54
55impl BillingInterval {
56    /// Get the number of days in this interval
57    #[must_use]
58    pub const fn days(&self) -> i64 {
59        match self {
60            Self::Weekly => 7,
61            Self::Biweekly => 14,
62            Self::Monthly => 30,
63            Self::Bimonthly => 60,
64            Self::Quarterly => 90,
65            Self::Semiannual => 180,
66            Self::Annual => 365,
67            Self::Custom => 30, // Default, should use custom_interval_days
68        }
69    }
70}
71
72/// Status of a subscription
73#[derive(
74    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
75)]
76#[serde(rename_all = "snake_case")]
77#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
78#[non_exhaustive]
79pub enum SubscriptionStatus {
80    /// Trial period (no charge yet)
81    Trial,
82    /// Active and billing normally
83    #[default]
84    Active,
85    /// Paused by customer (no billing, can resume)
86    Paused,
87    /// Past due - payment failed, in retry period
88    #[strum(serialize = "past_due", serialize = "pastdue")]
89    PastDue,
90    /// Cancelled - will end at period end
91    #[strum(serialize = "cancelled", serialize = "canceled")]
92    Cancelled,
93    /// Expired - subscription has ended
94    Expired,
95    /// Pending - awaiting initial payment/activation
96    Pending,
97}
98
99impl SubscriptionStatus {
100    /// Check if a status transition is allowed.
101    #[must_use]
102    pub fn can_transition_to(self, next: Self) -> bool {
103        if self == next {
104            return true;
105        }
106        match self {
107            Self::Pending => matches!(next, Self::Trial | Self::Active | Self::Cancelled),
108            Self::Trial => matches!(next, Self::Active | Self::Cancelled | Self::Expired),
109            Self::Active => {
110                matches!(next, Self::Paused | Self::PastDue | Self::Cancelled | Self::Expired)
111            }
112            Self::Paused => matches!(next, Self::Active | Self::Cancelled),
113            Self::PastDue => matches!(next, Self::Active | Self::Cancelled | Self::Expired),
114            Self::Cancelled | Self::Expired => false,
115        }
116    }
117
118    /// Returns true if this status is a terminal state.
119    #[must_use]
120    pub const fn is_terminal(self) -> bool {
121        matches!(self, Self::Cancelled | Self::Expired)
122    }
123}
124
125/// Status of a subscription plan
126#[derive(
127    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
128)]
129#[serde(rename_all = "snake_case")]
130#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
131#[non_exhaustive]
132pub enum PlanStatus {
133    /// Draft - not yet available
134    Draft,
135    /// Active - available for new subscriptions
136    #[default]
137    Active,
138    /// Archived - no new subscriptions, existing continue
139    Archived,
140}
141
142/// Status of a billing cycle
143#[derive(
144    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
145)]
146#[serde(rename_all = "snake_case")]
147#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
148#[non_exhaustive]
149pub enum BillingCycleStatus {
150    /// Scheduled for future billing
151    #[default]
152    Scheduled,
153    /// Payment is being processed
154    Processing,
155    /// Successfully billed
156    Paid,
157    /// Payment failed
158    Failed,
159    /// Skipped by customer request
160    Skipped,
161    /// Refunded
162    Refunded,
163    /// Voided/cancelled
164    Voided,
165}
166
167/// Type of subscription event
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
169#[serde(rename_all = "snake_case")]
170#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
171#[non_exhaustive]
172pub enum SubscriptionEventType {
173    /// Subscription created
174    Created,
175    /// Subscription activated
176    Activated,
177    /// Trial started
178    #[strum(serialize = "trial_started", serialize = "trialstarted")]
179    TrialStarted,
180    /// Trial ended
181    #[strum(serialize = "trial_ended", serialize = "trialended")]
182    TrialEnded,
183    /// Successfully renewed/billed
184    Renewed,
185    /// Payment failed
186    #[strum(serialize = "payment_failed", serialize = "paymentfailed")]
187    PaymentFailed,
188    /// Payment retry succeeded
189    #[strum(serialize = "payment_retry_succeeded", serialize = "paymentretrysucceeded")]
190    PaymentRetrySucceeded,
191    /// Subscription paused
192    Paused,
193    /// Subscription resumed
194    Resumed,
195    /// Upcoming renewal skipped
196    Skipped,
197    /// Subscription cancelled
198    #[strum(serialize = "cancelled", serialize = "canceled")]
199    Cancelled,
200    /// Subscription expired
201    Expired,
202    /// Plan changed
203    #[strum(serialize = "plan_changed", serialize = "planchanged")]
204    PlanChanged,
205    /// Items modified
206    #[strum(serialize = "items_modified", serialize = "itemsmodified")]
207    ItemsModified,
208    /// Quantity changed
209    #[strum(serialize = "quantity_changed", serialize = "quantitychanged")]
210    QuantityChanged,
211    /// Address updated
212    #[strum(serialize = "address_updated", serialize = "addressupdated")]
213    AddressUpdated,
214    /// Payment method updated
215    #[strum(serialize = "payment_method_updated", serialize = "paymentmethodupdated")]
216    PaymentMethodUpdated,
217    /// Discount applied
218    #[strum(serialize = "discount_applied", serialize = "discountapplied")]
219    DiscountApplied,
220    /// Discount removed
221    #[strum(serialize = "discount_removed", serialize = "discountremoved")]
222    DiscountRemoved,
223    /// Refund issued
224    Refunded,
225}
226
227// ============================================================================
228// Subscription Plan Model
229// ============================================================================
230
231/// A subscription plan template
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct SubscriptionPlan {
234    pub id: Uuid,
235    /// Unique code for the plan
236    pub code: String,
237    /// Display name
238    pub name: String,
239    /// Description
240    pub description: Option<String>,
241    pub status: PlanStatus,
242
243    // Billing configuration
244    pub billing_interval: BillingInterval,
245    /// Custom interval in days (when `billing_interval` is Custom)
246    pub custom_interval_days: Option<i32>,
247    /// Base price per billing cycle
248    pub price: Decimal,
249    /// Setup/activation fee (charged once)
250    pub setup_fee: Option<Decimal>,
251    pub currency: CurrencyCode,
252
253    // Trial configuration
254    /// Trial period in days (0 = no trial)
255    pub trial_days: i32,
256    /// Whether payment method is required for trial
257    pub trial_requires_payment_method: bool,
258
259    // Limits
260    /// Minimum number of billing cycles
261    pub min_cycles: Option<i32>,
262    /// Maximum number of billing cycles (None = unlimited)
263    pub max_cycles: Option<i32>,
264
265    // Included products/items
266    pub items: Vec<SubscriptionPlanItem>,
267
268    // Discounts
269    /// Percentage discount for this plan
270    pub discount_percent: Option<Decimal>,
271    /// Fixed discount amount
272    pub discount_amount: Option<Decimal>,
273
274    // Metadata
275    pub metadata: Option<serde_json::Value>,
276    pub created_at: DateTime<Utc>,
277    pub updated_at: DateTime<Utc>,
278}
279
280/// An item included in a subscription plan
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct SubscriptionPlanItem {
283    pub id: Uuid,
284    pub plan_id: Uuid,
285    pub product_id: ProductId,
286    pub variant_id: Option<Uuid>,
287    pub sku: String,
288    pub name: String,
289    /// Default quantity
290    pub quantity: i32,
291    /// Minimum quantity (for customizable plans)
292    pub min_quantity: Option<i32>,
293    /// Maximum quantity (for customizable plans)
294    pub max_quantity: Option<i32>,
295    /// Whether customer can remove this item
296    pub is_required: bool,
297    /// Unit price override (None = use plan price)
298    pub unit_price: Option<Decimal>,
299}
300
301// ============================================================================
302// Subscription Model
303// ============================================================================
304
305/// A customer's subscription
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct Subscription {
308    pub id: SubscriptionId,
309    /// Human-readable subscription number
310    pub subscription_number: String,
311    pub customer_id: CustomerId,
312    pub plan_id: Uuid,
313    /// Snapshot of plan name at time of subscription
314    pub plan_name: String,
315    pub status: SubscriptionStatus,
316
317    // Billing
318    pub billing_interval: BillingInterval,
319    pub custom_interval_days: Option<i32>,
320    /// Current price per cycle
321    pub price: Decimal,
322    pub currency: CurrencyCode,
323    /// Payment method ID (from payment provider)
324    pub payment_method_id: Option<String>,
325
326    // Dates
327    /// When the subscription started
328    pub started_at: DateTime<Utc>,
329    /// When current billing period started
330    pub current_period_start: DateTime<Utc>,
331    /// When current billing period ends
332    pub current_period_end: DateTime<Utc>,
333    /// Next billing date
334    pub next_billing_date: Option<DateTime<Utc>>,
335    /// Trial end date (if applicable)
336    pub trial_ends_at: Option<DateTime<Utc>>,
337    /// When subscription was cancelled (if applicable)
338    pub cancelled_at: Option<DateTime<Utc>>,
339    /// When subscription ends (for cancelled subscriptions)
340    pub ends_at: Option<DateTime<Utc>>,
341    /// When subscription was paused
342    pub paused_at: Option<DateTime<Utc>>,
343    /// When to auto-resume (if paused with a date)
344    pub resume_at: Option<DateTime<Utc>>,
345
346    // Cycle tracking
347    /// Number of completed billing cycles
348    pub billing_cycle_count: i32,
349    /// Number of failed payment attempts in current cycle
350    pub failed_payment_attempts: i32,
351
352    // Items
353    pub items: Vec<SubscriptionItem>,
354
355    // Addresses
356    pub shipping_address: Option<Address>,
357    pub billing_address: Option<Address>,
358
359    // Applied discounts
360    pub discount_percent: Option<Decimal>,
361    pub discount_amount: Option<Decimal>,
362    /// Coupon code applied (if any)
363    pub coupon_code: Option<String>,
364
365    // Metadata
366    pub metadata: Option<serde_json::Value>,
367    pub created_at: DateTime<Utc>,
368    pub updated_at: DateTime<Utc>,
369}
370
371/// A line item in a subscription
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct SubscriptionItem {
374    pub id: Uuid,
375    pub subscription_id: SubscriptionId,
376    pub product_id: ProductId,
377    pub variant_id: Option<Uuid>,
378    pub sku: String,
379    pub name: String,
380    pub quantity: i32,
381    /// Price per unit
382    pub unit_price: Decimal,
383    /// Line total (quantity * `unit_price`)
384    pub line_total: Decimal,
385}
386
387// ============================================================================
388// Billing Cycle Model
389// ============================================================================
390
391/// A billing cycle/period for a subscription
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct BillingCycle {
394    pub id: Uuid,
395    pub subscription_id: SubscriptionId,
396    /// Cycle number (1, 2, 3, ...)
397    pub cycle_number: i32,
398    pub status: BillingCycleStatus,
399
400    // Period
401    pub period_start: DateTime<Utc>,
402    pub period_end: DateTime<Utc>,
403    /// When billing was attempted
404    pub billed_at: Option<DateTime<Utc>>,
405
406    // Amounts
407    pub subtotal: Decimal,
408    pub discount: Decimal,
409    pub tax: Decimal,
410    pub total: Decimal,
411    pub currency: CurrencyCode,
412
413    // Payment
414    /// Payment ID from payment provider
415    pub payment_id: Option<String>,
416    /// Order ID if an order was created
417    pub order_id: Option<OrderId>,
418    /// Invoice ID if an invoice was created
419    pub invoice_id: Option<Uuid>,
420
421    // Failure tracking
422    pub failure_reason: Option<String>,
423    pub retry_count: i32,
424    pub next_retry_at: Option<DateTime<Utc>>,
425
426    pub created_at: DateTime<Utc>,
427    pub updated_at: DateTime<Utc>,
428}
429
430// ============================================================================
431// Subscription Event (Audit Log)
432// ============================================================================
433
434/// An event in a subscription's history
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct SubscriptionEvent {
437    pub id: Uuid,
438    pub subscription_id: SubscriptionId,
439    pub event_type: SubscriptionEventType,
440    /// Human-readable description
441    pub description: String,
442    /// Detailed event data
443    pub data: Option<serde_json::Value>,
444    /// Who triggered this event (`customer_id`, "system", "admin")
445    pub triggered_by: Option<String>,
446    pub created_at: DateTime<Utc>,
447}
448
449// ============================================================================
450// CRUD DTOs
451// ============================================================================
452
453/// Create a new subscription plan
454#[derive(Debug, Clone, Serialize, Deserialize, Default)]
455pub struct CreateSubscriptionPlan {
456    pub code: Option<String>,
457    pub name: String,
458    pub description: Option<String>,
459    pub billing_interval: BillingInterval,
460    pub custom_interval_days: Option<i32>,
461    pub price: Decimal,
462    pub setup_fee: Option<Decimal>,
463    pub currency: Option<CurrencyCode>,
464    pub trial_days: Option<i32>,
465    pub trial_requires_payment_method: Option<bool>,
466    pub min_cycles: Option<i32>,
467    pub max_cycles: Option<i32>,
468    pub items: Option<Vec<CreateSubscriptionPlanItem>>,
469    pub discount_percent: Option<Decimal>,
470    pub discount_amount: Option<Decimal>,
471    pub metadata: Option<serde_json::Value>,
472}
473
474/// Item definition for creating a subscription plan
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct CreateSubscriptionPlanItem {
477    pub product_id: ProductId,
478    pub variant_id: Option<Uuid>,
479    pub sku: String,
480    pub name: String,
481    pub quantity: i32,
482    pub min_quantity: Option<i32>,
483    pub max_quantity: Option<i32>,
484    pub is_required: Option<bool>,
485    pub unit_price: Option<Decimal>,
486}
487
488/// Update a subscription plan
489#[derive(Debug, Clone, Serialize, Deserialize, Default)]
490pub struct UpdateSubscriptionPlan {
491    pub name: Option<String>,
492    pub description: Option<String>,
493    pub status: Option<PlanStatus>,
494    pub price: Option<Decimal>,
495    pub setup_fee: Option<Decimal>,
496    pub trial_days: Option<i32>,
497    pub trial_requires_payment_method: Option<bool>,
498    pub min_cycles: Option<i32>,
499    pub max_cycles: Option<i32>,
500    pub discount_percent: Option<Decimal>,
501    pub discount_amount: Option<Decimal>,
502    pub metadata: Option<serde_json::Value>,
503}
504
505/// Create a new subscription
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct CreateSubscription {
508    pub customer_id: CustomerId,
509    pub plan_id: Uuid,
510    /// Override plan items with custom items
511    pub items: Option<Vec<CreateSubscriptionItem>>,
512    /// Override plan price
513    pub price: Option<Decimal>,
514    pub payment_method_id: Option<String>,
515    pub shipping_address: Option<Address>,
516    pub billing_address: Option<Address>,
517    /// Skip trial period
518    pub skip_trial: Option<bool>,
519    /// Start date (default: now)
520    pub start_date: Option<DateTime<Utc>>,
521    /// Coupon code to apply
522    pub coupon_code: Option<String>,
523    pub metadata: Option<serde_json::Value>,
524}
525
526impl Default for CreateSubscription {
527    fn default() -> Self {
528        Self {
529            customer_id: CustomerId::nil(),
530            plan_id: Uuid::nil(),
531            items: None,
532            price: None,
533            payment_method_id: None,
534            shipping_address: None,
535            billing_address: None,
536            skip_trial: None,
537            start_date: None,
538            coupon_code: None,
539            metadata: None,
540        }
541    }
542}
543
544/// Item override when creating a subscription
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct CreateSubscriptionItem {
547    pub product_id: ProductId,
548    pub variant_id: Option<Uuid>,
549    pub sku: String,
550    pub name: String,
551    pub quantity: i32,
552    pub unit_price: Option<Decimal>,
553}
554
555/// Update a subscription
556#[derive(Debug, Clone, Serialize, Deserialize, Default)]
557pub struct UpdateSubscription {
558    pub status: Option<SubscriptionStatus>,
559    pub price: Option<Decimal>,
560    pub payment_method_id: Option<String>,
561    pub shipping_address: Option<Address>,
562    pub billing_address: Option<Address>,
563    pub next_billing_date: Option<DateTime<Utc>>,
564    pub discount_percent: Option<Decimal>,
565    pub discount_amount: Option<Decimal>,
566    pub coupon_code: Option<String>,
567    pub metadata: Option<serde_json::Value>,
568}
569
570/// Pause subscription request
571#[derive(Debug, Clone, Serialize, Deserialize, Default)]
572pub struct PauseSubscription {
573    /// Optional resume date
574    pub resume_at: Option<DateTime<Utc>>,
575    pub reason: Option<String>,
576}
577
578/// Cancel subscription request
579#[derive(Debug, Clone, Serialize, Deserialize, Default)]
580pub struct CancelSubscription {
581    /// Cancel immediately or at period end
582    pub immediate: Option<bool>,
583    pub reason: Option<String>,
584    /// Feedback for cancellation
585    pub feedback: Option<String>,
586}
587
588/// Skip next billing cycle
589#[derive(Debug, Clone, Serialize, Deserialize, Default)]
590pub struct SkipBillingCycle {
591    pub reason: Option<String>,
592}
593
594/// Create a billing cycle for a subscription.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct CreateBillingCycle {
597    pub subscription_id: SubscriptionId,
598    pub cycle_number: i32,
599    pub period_start: DateTime<Utc>,
600    pub period_end: DateTime<Utc>,
601}
602
603/// Change subscription plan
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct ChangeSubscriptionPlan {
606    pub new_plan_id: Uuid,
607    /// Prorate charges (default: true)
608    pub prorate: Option<bool>,
609    /// Apply immediately or at next billing
610    pub immediate: Option<bool>,
611}
612
613/// Modify subscription items
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct ModifySubscriptionItems {
616    /// Items to add
617    pub add: Option<Vec<CreateSubscriptionItem>>,
618    /// Item IDs to remove
619    pub remove: Option<Vec<Uuid>>,
620    /// Items to update (id -> new quantity)
621    pub update_quantities: Option<Vec<UpdateItemQuantity>>,
622}
623
624/// Update quantity for an existing subscription item
625#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct UpdateItemQuantity {
627    pub item_id: Uuid,
628    pub quantity: i32,
629}
630
631// ============================================================================
632// Filter DTOs
633// ============================================================================
634
635/// Filter for listing subscription plans
636#[derive(Debug, Clone, Serialize, Deserialize, Default)]
637pub struct SubscriptionPlanFilter {
638    pub status: Option<PlanStatus>,
639    pub billing_interval: Option<BillingInterval>,
640    pub search: Option<String>,
641    pub limit: Option<u32>,
642    pub offset: Option<u32>,
643}
644
645/// Filter for listing subscriptions
646#[derive(Debug, Clone, Serialize, Deserialize, Default)]
647pub struct SubscriptionFilter {
648    pub customer_id: Option<CustomerId>,
649    pub plan_id: Option<Uuid>,
650    pub status: Option<SubscriptionStatus>,
651    pub from_date: Option<DateTime<Utc>>,
652    pub to_date: Option<DateTime<Utc>>,
653    pub search: Option<String>,
654    pub limit: Option<u32>,
655    pub offset: Option<u32>,
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use std::collections::HashSet;
662    use std::str::FromStr;
663
664    #[test]
665    fn test_billing_interval_from_str() {
666        assert_eq!(BillingInterval::from_str("biweekly").unwrap(), BillingInterval::Biweekly);
667        assert_eq!(BillingInterval::from_str("semi-annual").unwrap(), BillingInterval::Semiannual);
668    }
669
670    #[test]
671    fn test_subscription_status_from_str() {
672        assert_eq!(SubscriptionStatus::from_str("past_due").unwrap(), SubscriptionStatus::PastDue);
673        assert_eq!(SubscriptionStatus::from_str("pastdue").unwrap(), SubscriptionStatus::PastDue);
674        assert_eq!(
675            SubscriptionStatus::from_str("canceled").unwrap(),
676            SubscriptionStatus::Cancelled
677        );
678    }
679
680    #[test]
681    fn test_plan_status_from_str() {
682        assert_eq!(PlanStatus::from_str("draft").unwrap(), PlanStatus::Draft);
683        assert_eq!(PlanStatus::from_str("archived").unwrap(), PlanStatus::Archived);
684    }
685
686    #[test]
687    fn test_billing_cycle_status_from_str() {
688        assert_eq!(
689            BillingCycleStatus::from_str("processing").unwrap(),
690            BillingCycleStatus::Processing
691        );
692        assert_eq!(BillingCycleStatus::from_str("voided").unwrap(), BillingCycleStatus::Voided);
693    }
694
695    #[test]
696    fn test_subscription_event_type_from_str() {
697        assert_eq!(
698            SubscriptionEventType::from_str("trial_started").unwrap(),
699            SubscriptionEventType::TrialStarted
700        );
701        assert_eq!(
702            SubscriptionEventType::from_str("trialstarted").unwrap(),
703            SubscriptionEventType::TrialStarted
704        );
705        assert_eq!(
706            SubscriptionEventType::from_str("payment_retry_succeeded").unwrap(),
707            SubscriptionEventType::PaymentRetrySucceeded
708        );
709    }
710
711    #[test]
712    fn test_generate_subscription_number_prefix_and_shape() {
713        let generated = generate_subscription_number();
714        assert!(generated.starts_with("SUB-"));
715        assert!(generated.len() > 12);
716    }
717
718    #[test]
719    fn test_generate_subscription_number_sample_uniqueness() {
720        let mut generated = HashSet::new();
721        for _ in 0..1000 {
722            generated.insert(generate_subscription_number());
723        }
724        assert_eq!(generated.len(), 1000);
725    }
726
727    #[test]
728    fn subscription_status_valid_transitions() {
729        use SubscriptionStatus::*;
730        assert!(Pending.can_transition_to(Trial));
731        assert!(Pending.can_transition_to(Active));
732        assert!(Pending.can_transition_to(Cancelled));
733        assert!(Trial.can_transition_to(Active));
734        assert!(Trial.can_transition_to(Cancelled));
735        assert!(Trial.can_transition_to(Expired));
736        assert!(Active.can_transition_to(Paused));
737        assert!(Active.can_transition_to(PastDue));
738        assert!(Active.can_transition_to(Cancelled));
739        assert!(Active.can_transition_to(Expired));
740        assert!(Paused.can_transition_to(Active));
741        assert!(Paused.can_transition_to(Cancelled));
742        assert!(PastDue.can_transition_to(Active));
743        assert!(PastDue.can_transition_to(Cancelled));
744        assert!(PastDue.can_transition_to(Expired));
745    }
746
747    #[test]
748    fn subscription_status_invalid_transitions() {
749        use SubscriptionStatus::*;
750        assert!(!Pending.can_transition_to(Paused));
751        assert!(!Pending.can_transition_to(PastDue));
752        assert!(!Trial.can_transition_to(Paused));
753        assert!(!Paused.can_transition_to(Trial));
754        assert!(!Cancelled.can_transition_to(Active));
755        assert!(!Expired.can_transition_to(Active));
756    }
757
758    #[test]
759    fn subscription_status_terminal_states() {
760        use SubscriptionStatus::*;
761        assert!(Cancelled.is_terminal());
762        assert!(Expired.is_terminal());
763        assert!(!Pending.is_terminal());
764        assert!(!Trial.is_terminal());
765        assert!(!Active.is_terminal());
766        assert!(!Paused.is_terminal());
767        assert!(!PastDue.is_terminal());
768    }
769}
770
771/// Filter for listing billing cycles
772#[derive(Debug, Clone, Serialize, Deserialize, Default)]
773pub struct BillingCycleFilter {
774    pub subscription_id: Option<SubscriptionId>,
775    pub status: Option<BillingCycleStatus>,
776    pub from_date: Option<DateTime<Utc>>,
777    pub to_date: Option<DateTime<Utc>>,
778    pub limit: Option<u32>,
779    pub offset: Option<u32>,
780}
781
782// ============================================================================
783// Helper Functions
784// ============================================================================
785
786/// Generate a unique subscription number
787#[must_use]
788pub fn generate_subscription_number() -> String {
789    let timestamp_ms = Utc::now().timestamp_millis();
790    let id = Uuid::new_v4();
791    let bytes = id.as_bytes();
792    let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
793    format!("SUB-{timestamp_ms}-{random:08X}")
794}
795
796/// Generate a unique plan code
797#[must_use]
798pub fn generate_plan_code(name: &str) -> String {
799    let slug: String = name
800        .to_uppercase()
801        .chars()
802        .filter(|c| c.is_alphanumeric() || *c == ' ')
803        .take(20)
804        .collect::<String>()
805        .trim()
806        .replace(' ', "-");
807
808    let id = Uuid::new_v4();
809    let bytes = id.as_bytes();
810    let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 10000;
811
812    if slug.is_empty() { format!("PLAN-{random:04}") } else { format!("{slug}-{random:04}") }
813}
814
815impl Subscription {
816    /// Check if subscription is in an active billing state
817    #[must_use]
818    pub const fn is_active(&self) -> bool {
819        matches!(self.status, SubscriptionStatus::Active | SubscriptionStatus::Trial)
820    }
821
822    /// Check if subscription can be paused
823    #[must_use]
824    pub const fn can_pause(&self) -> bool {
825        matches!(self.status, SubscriptionStatus::Active | SubscriptionStatus::Trial)
826    }
827
828    /// Check if subscription can be resumed
829    #[must_use]
830    pub fn can_resume(&self) -> bool {
831        self.status == SubscriptionStatus::Paused
832    }
833
834    /// Check if subscription can be cancelled
835    #[must_use]
836    pub const fn can_cancel(&self) -> bool {
837        !matches!(self.status, SubscriptionStatus::Cancelled | SubscriptionStatus::Expired)
838    }
839
840    /// Check if subscription is in trial period
841    #[must_use]
842    pub fn is_in_trial(&self) -> bool {
843        if self.status != SubscriptionStatus::Trial {
844            return false;
845        }
846
847        if let Some(trial_ends) = self.trial_ends_at {
848            return Utc::now() < trial_ends;
849        }
850
851        false
852    }
853
854    /// Calculate remaining trial days
855    #[must_use]
856    pub fn trial_days_remaining(&self) -> Option<i64> {
857        if !self.is_in_trial() {
858            return None;
859        }
860
861        self.trial_ends_at.map(|ends| {
862            let now = Utc::now();
863            if ends > now { (ends - now).num_days() } else { 0 }
864        })
865    }
866
867    /// Calculate total subscription value
868    #[must_use]
869    pub fn calculate_total(&self) -> Decimal {
870        self.items.iter().map(|item| item.line_total).sum()
871    }
872
873    /// Get next billing amount (after discounts)
874    #[must_use]
875    pub fn next_billing_amount(&self) -> Decimal {
876        let subtotal = self.calculate_total();
877        let mut total = subtotal;
878
879        if let Some(pct) = self.discount_percent {
880            total -= subtotal * pct;
881        }
882        if let Some(amt) = self.discount_amount {
883            total -= amt;
884        }
885
886        if total < Decimal::ZERO { Decimal::ZERO } else { total }
887    }
888}
889
890impl SubscriptionItem {
891    /// Calculate line total
892    #[must_use]
893    pub fn calculate_total(quantity: i32, unit_price: Decimal) -> Decimal {
894        unit_price * Decimal::from(quantity)
895    }
896}
897
898impl BillingCycle {
899    /// Check if cycle can be refunded
900    #[must_use]
901    pub fn can_refund(&self) -> bool {
902        self.status == BillingCycleStatus::Paid
903    }
904
905    /// Check if cycle can be retried
906    #[must_use]
907    pub fn can_retry(&self) -> bool {
908        self.status == BillingCycleStatus::Failed
909    }
910}
911
912// ============================================================================
913// Validation
914// ============================================================================
915
916use crate::validation::{Validate, ValidationBuilder};
917
918/// Shared pricing checks: money fields non-negative, `discount_percent` a
919/// fraction in `[0, 1]` (the billing computation multiplies it directly by
920/// the subtotal, so `10` would mean 1000%).
921fn pricing_checks(
922    builder: ValidationBuilder,
923    price: Option<Decimal>,
924    setup_fee: Option<Decimal>,
925    discount_percent: Option<Decimal>,
926    discount_amount: Option<Decimal>,
927) -> ValidationBuilder {
928    builder
929        .check("price", price.is_none_or(|p| p >= Decimal::ZERO), "cannot be negative")
930        .check("setup_fee", setup_fee.is_none_or(|f| f >= Decimal::ZERO), "cannot be negative")
931        .check(
932            "discount_percent",
933            discount_percent.is_none_or(|p| p >= Decimal::ZERO && p <= Decimal::ONE),
934            "must be a fraction between 0 and 1",
935        )
936        .check(
937            "discount_amount",
938            discount_amount.is_none_or(|a| a >= Decimal::ZERO),
939            "cannot be negative",
940        )
941}
942
943impl Validate for CreateSubscriptionPlan {
944    fn validate(&self) -> crate::Result<()> {
945        pricing_checks(
946            ValidationBuilder::new(),
947            Some(self.price),
948            self.setup_fee,
949            self.discount_percent,
950            self.discount_amount,
951        )
952        .build()
953    }
954}
955
956impl Validate for UpdateSubscriptionPlan {
957    fn validate(&self) -> crate::Result<()> {
958        pricing_checks(
959            ValidationBuilder::new(),
960            self.price,
961            self.setup_fee,
962            self.discount_percent,
963            self.discount_amount,
964        )
965        .build()
966    }
967}
968
969impl Validate for CreateSubscription {
970    fn validate(&self) -> crate::Result<()> {
971        pricing_checks(ValidationBuilder::new(), self.price, None, None, None).build()
972    }
973}
974
975impl Validate for UpdateSubscription {
976    fn validate(&self) -> crate::Result<()> {
977        pricing_checks(
978            ValidationBuilder::new(),
979            self.price,
980            None,
981            self.discount_percent,
982            self.discount_amount,
983        )
984        .build()
985    }
986}