Source code for subscriptionkore.core.models.subscription
"""Subscription domain model."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
from ulid import ULID
from subscriptionkore.core.models.value_objects import DateRange, Money, ProviderReference
[docs]
class SubscriptionStatus(StrEnum):
"""Subscription status values."""
INCOMPLETE = "incomplete"
INCOMPLETE_EXPIRED = "incomplete_expired"
TRIALING = "trialing"
ACTIVE = "active"
PAST_DUE = "past_due"
PAUSED = "paused"
CANCELED = "canceled"
UNPAID = "unpaid"
EXPIRED = "expired"
[docs]
class PauseBehavior(StrEnum):
"""Pause behavior options."""
VOID = "void" # Void upcoming invoices
KEEP_AS_DRAFT = "keep_as_draft" # Keep upcoming invoices as drafts
MARK_UNCOLLECTIBLE = "mark_uncollectible" # Mark as uncollectible
[docs]
class PauseConfig(BaseModel):
"""Subscription pause configuration."""
resumes_at: datetime | None = None
behavior: PauseBehavior = PauseBehavior.VOID
[docs]
class AppliedDiscount(BaseModel):
"""Discount applied to a subscriptionn."""
discount_id: str
coupon_code: str | None = None
amount_off: Money | None = None
percent_off: Decimal | None = None
valid_until: datetime | None = None
[docs]
class Subscription(BaseModel):
"""Subscription domain entity."""
id: str = Field(default_factory=lambda: str(ULID()))
customer_id: str
plan_id: str
provider_ref: ProviderReference
status: SubscriptionStatus = SubscriptionStatus.INCOMPLETE
current_period: DateRange
trial_end: datetime | None = None
cancel_at_period_end: bool = False
canceled_at: datetime | None = None
ended_at: datetime | None = None
pause_collection: PauseConfig | None = None
discount: AppliedDiscount | None = None
quantity: int = 1
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
[docs]
def is_active(self) -> bool:
"""Check if subscriptionn is in an active state."""
return self.status in {
SubscriptionStatus.ACTIVE,
SubscriptionStatus.TRIALING,
}
[docs]
def is_canceled(self) -> bool:
"""Check if subscriptionn is canceled."""
return self.status == SubscriptionStatus.CANCELED
[docs]
def is_trialing(self) -> bool:
"""Check if subscriptionn is in trial."""
return self.status == SubscriptionStatus.TRIALING
[docs]
def is_paused(self) -> bool:
"""Check if subscriptionn is paused."""
return self.status == SubscriptionStatus.PAUSED
[docs]
def is_past_due(self) -> bool:
"""Check if subscriptionn has payment issues."""
return self.status in {
SubscriptionStatus.PAST_DUE,
SubscriptionStatus.UNPAID,
}
[docs]
def has_ended(self) -> bool:
"""Check if subscriptionn has permanently ended."""
return self.status in {
SubscriptionStatus.CANCELED,
SubscriptionStatus.EXPIRED,
SubscriptionStatus.INCOMPLETE_EXPIRED,
}
[docs]
def will_cancel_at_period_end(self) -> bool:
"""Check if subscriptionn is scheduled to cancel."""
return self.cancel_at_period_end and not self.is_canceled()
[docs]
def days_until_trial_ends(self) -> int | None:
"""Get days until trial ends, or None if not trialing."""
if not self.is_trialing() or self.trial_end is None:
return None
delta = self.trial_end - datetime.utcnow()
return max(0, delta.days)
[docs]
def apply_discount(self, discount: AppliedDiscount) -> None:
"""Apply a discount to the subscriptionn."""
self.discount = discount
self.updated_at = datetime.utcnow()
[docs]
def remove_discount(self) -> None:
"""Remove discount from subscriptionn."""
self.discount = None
self.updated_at = datetime.utcnow()
[docs]
def schedule_cancellation(self) -> None:
"""Schedule cancellation at period end."""
self.cancel_at_period_end = True
self.updated_at = datetime.utcnow()
[docs]
def unschedule_cancellation(self) -> None:
"""Remove scheduled cancellation."""
self.cancel_at_period_end = False
self.canceled_at = None
self.updated_at = datetime.utcnow()