1use 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#[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,
34 #[strum(serialize = "biweekly", serialize = "bi_weekly", serialize = "bi-weekly")]
36 Biweekly,
37 #[default]
39 Monthly,
40 #[strum(serialize = "bimonthly", serialize = "bi_monthly", serialize = "bi-monthly")]
42 Bimonthly,
43 Quarterly,
45 #[strum(serialize = "semiannual", serialize = "semi_annual", serialize = "semi-annual")]
47 Semiannual,
48 #[strum(serialize = "annual", serialize = "yearly")]
50 Annual,
51 Custom,
53}
54
55impl BillingInterval {
56 #[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, }
69 }
70}
71
72#[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,
82 #[default]
84 Active,
85 Paused,
87 #[strum(serialize = "past_due", serialize = "pastdue")]
89 PastDue,
90 #[strum(serialize = "cancelled", serialize = "canceled")]
92 Cancelled,
93 Expired,
95 Pending,
97}
98
99impl SubscriptionStatus {
100 #[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 #[must_use]
120 pub const fn is_terminal(self) -> bool {
121 matches!(self, Self::Cancelled | Self::Expired)
122 }
123}
124
125#[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,
135 #[default]
137 Active,
138 Archived,
140}
141
142#[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 #[default]
152 Scheduled,
153 Processing,
155 Paid,
157 Failed,
159 Skipped,
161 Refunded,
163 Voided,
165}
166
167#[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 Created,
175 Activated,
177 #[strum(serialize = "trial_started", serialize = "trialstarted")]
179 TrialStarted,
180 #[strum(serialize = "trial_ended", serialize = "trialended")]
182 TrialEnded,
183 Renewed,
185 #[strum(serialize = "payment_failed", serialize = "paymentfailed")]
187 PaymentFailed,
188 #[strum(serialize = "payment_retry_succeeded", serialize = "paymentretrysucceeded")]
190 PaymentRetrySucceeded,
191 Paused,
193 Resumed,
195 Skipped,
197 #[strum(serialize = "cancelled", serialize = "canceled")]
199 Cancelled,
200 Expired,
202 #[strum(serialize = "plan_changed", serialize = "planchanged")]
204 PlanChanged,
205 #[strum(serialize = "items_modified", serialize = "itemsmodified")]
207 ItemsModified,
208 #[strum(serialize = "quantity_changed", serialize = "quantitychanged")]
210 QuantityChanged,
211 #[strum(serialize = "address_updated", serialize = "addressupdated")]
213 AddressUpdated,
214 #[strum(serialize = "payment_method_updated", serialize = "paymentmethodupdated")]
216 PaymentMethodUpdated,
217 #[strum(serialize = "discount_applied", serialize = "discountapplied")]
219 DiscountApplied,
220 #[strum(serialize = "discount_removed", serialize = "discountremoved")]
222 DiscountRemoved,
223 Refunded,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct SubscriptionPlan {
234 pub id: Uuid,
235 pub code: String,
237 pub name: String,
239 pub description: Option<String>,
241 pub status: PlanStatus,
242
243 pub billing_interval: BillingInterval,
245 pub custom_interval_days: Option<i32>,
247 pub price: Decimal,
249 pub setup_fee: Option<Decimal>,
251 pub currency: CurrencyCode,
252
253 pub trial_days: i32,
256 pub trial_requires_payment_method: bool,
258
259 pub min_cycles: Option<i32>,
262 pub max_cycles: Option<i32>,
264
265 pub items: Vec<SubscriptionPlanItem>,
267
268 pub discount_percent: Option<Decimal>,
271 pub discount_amount: Option<Decimal>,
273
274 pub metadata: Option<serde_json::Value>,
276 pub created_at: DateTime<Utc>,
277 pub updated_at: DateTime<Utc>,
278}
279
280#[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 pub quantity: i32,
291 pub min_quantity: Option<i32>,
293 pub max_quantity: Option<i32>,
295 pub is_required: bool,
297 pub unit_price: Option<Decimal>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct Subscription {
308 pub id: SubscriptionId,
309 pub subscription_number: String,
311 pub customer_id: CustomerId,
312 pub plan_id: Uuid,
313 pub plan_name: String,
315 pub status: SubscriptionStatus,
316
317 pub billing_interval: BillingInterval,
319 pub custom_interval_days: Option<i32>,
320 pub price: Decimal,
322 pub currency: CurrencyCode,
323 pub payment_method_id: Option<String>,
325
326 pub started_at: DateTime<Utc>,
329 pub current_period_start: DateTime<Utc>,
331 pub current_period_end: DateTime<Utc>,
333 pub next_billing_date: Option<DateTime<Utc>>,
335 pub trial_ends_at: Option<DateTime<Utc>>,
337 pub cancelled_at: Option<DateTime<Utc>>,
339 pub ends_at: Option<DateTime<Utc>>,
341 pub paused_at: Option<DateTime<Utc>>,
343 pub resume_at: Option<DateTime<Utc>>,
345
346 pub billing_cycle_count: i32,
349 pub failed_payment_attempts: i32,
351
352 pub items: Vec<SubscriptionItem>,
354
355 pub shipping_address: Option<Address>,
357 pub billing_address: Option<Address>,
358
359 pub discount_percent: Option<Decimal>,
361 pub discount_amount: Option<Decimal>,
362 pub coupon_code: Option<String>,
364
365 pub metadata: Option<serde_json::Value>,
367 pub created_at: DateTime<Utc>,
368 pub updated_at: DateTime<Utc>,
369}
370
371#[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 pub unit_price: Decimal,
383 pub line_total: Decimal,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct BillingCycle {
394 pub id: Uuid,
395 pub subscription_id: SubscriptionId,
396 pub cycle_number: i32,
398 pub status: BillingCycleStatus,
399
400 pub period_start: DateTime<Utc>,
402 pub period_end: DateTime<Utc>,
403 pub billed_at: Option<DateTime<Utc>>,
405
406 pub subtotal: Decimal,
408 pub discount: Decimal,
409 pub tax: Decimal,
410 pub total: Decimal,
411 pub currency: CurrencyCode,
412
413 pub payment_id: Option<String>,
416 pub order_id: Option<OrderId>,
418 pub invoice_id: Option<Uuid>,
420
421 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#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct SubscriptionEvent {
437 pub id: Uuid,
438 pub subscription_id: SubscriptionId,
439 pub event_type: SubscriptionEventType,
440 pub description: String,
442 pub data: Option<serde_json::Value>,
444 pub triggered_by: Option<String>,
446 pub created_at: DateTime<Utc>,
447}
448
449#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct CreateSubscription {
508 pub customer_id: CustomerId,
509 pub plan_id: Uuid,
510 pub items: Option<Vec<CreateSubscriptionItem>>,
512 pub price: Option<Decimal>,
514 pub payment_method_id: Option<String>,
515 pub shipping_address: Option<Address>,
516 pub billing_address: Option<Address>,
517 pub skip_trial: Option<bool>,
519 pub start_date: Option<DateTime<Utc>>,
521 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
572pub struct PauseSubscription {
573 pub resume_at: Option<DateTime<Utc>>,
575 pub reason: Option<String>,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, Default)]
580pub struct CancelSubscription {
581 pub immediate: Option<bool>,
583 pub reason: Option<String>,
584 pub feedback: Option<String>,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize, Default)]
590pub struct SkipBillingCycle {
591 pub reason: Option<String>,
592}
593
594#[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#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct ChangeSubscriptionPlan {
606 pub new_plan_id: Uuid,
607 pub prorate: Option<bool>,
609 pub immediate: Option<bool>,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct ModifySubscriptionItems {
616 pub add: Option<Vec<CreateSubscriptionItem>>,
618 pub remove: Option<Vec<Uuid>>,
620 pub update_quantities: Option<Vec<UpdateItemQuantity>>,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct UpdateItemQuantity {
627 pub item_id: Uuid,
628 pub quantity: i32,
629}
630
631#[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#[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#[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#[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#[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 #[must_use]
818 pub const fn is_active(&self) -> bool {
819 matches!(self.status, SubscriptionStatus::Active | SubscriptionStatus::Trial)
820 }
821
822 #[must_use]
824 pub const fn can_pause(&self) -> bool {
825 matches!(self.status, SubscriptionStatus::Active | SubscriptionStatus::Trial)
826 }
827
828 #[must_use]
830 pub fn can_resume(&self) -> bool {
831 self.status == SubscriptionStatus::Paused
832 }
833
834 #[must_use]
836 pub const fn can_cancel(&self) -> bool {
837 !matches!(self.status, SubscriptionStatus::Cancelled | SubscriptionStatus::Expired)
838 }
839
840 #[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 #[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 #[must_use]
869 pub fn calculate_total(&self) -> Decimal {
870 self.items.iter().map(|item| item.line_total).sum()
871 }
872
873 #[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 #[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 #[must_use]
901 pub fn can_refund(&self) -> bool {
902 self.status == BillingCycleStatus::Paid
903 }
904
905 #[must_use]
907 pub fn can_retry(&self) -> bool {
908 self.status == BillingCycleStatus::Failed
909 }
910}
911
912use crate::validation::{Validate, ValidationBuilder};
917
918fn 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}