181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
import logging
|
|
from datetime import timedelta
|
|
from decimal import Decimal
|
|
|
|
from django.utils import timezone
|
|
from django.utils.module_loading import import_string
|
|
|
|
from infrasynth.shared.enums import InvoiceStatus, SubscriptionStatus
|
|
from infrasynth.shared.settings_utils import get_setting
|
|
|
|
from .models import BillingPlan, Invoice, PaymentGateway, Subscription
|
|
from .signals import (
|
|
invoice_generated,
|
|
subscription_cancelled,
|
|
subscription_created,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BillingService:
|
|
"""Public API for subscriptions, checkouts, and invoices."""
|
|
|
|
def create_checkout_session(
|
|
self,
|
|
plan_slug: str,
|
|
user,
|
|
success_url: str | None = None,
|
|
cancel_url: str | None = None,
|
|
):
|
|
"""Creates a checkout session in the plan's payment gateway."""
|
|
plan = BillingPlan.objects.select_related("gateway").get(slug=plan_slug, is_active=True)
|
|
gateway = plan.gateway or PaymentGateway.objects.filter(is_active=True).first()
|
|
if gateway is None:
|
|
raise ValueError("No active payment gateway configured for this plan.")
|
|
|
|
gateway_instance = self._get_gateway(gateway)
|
|
result = gateway_instance.create_checkout_session(
|
|
plan,
|
|
user,
|
|
success_url=success_url,
|
|
cancel_url=cancel_url,
|
|
)
|
|
return result, gateway
|
|
|
|
def create_subscription(
|
|
self,
|
|
plan: BillingPlan,
|
|
user,
|
|
gateway: PaymentGateway,
|
|
external_id: str = "",
|
|
) -> Subscription:
|
|
"""Creates a subscription record after checkout confirmation."""
|
|
now = timezone.now()
|
|
interval_days = 365 if plan.interval == "yearly" else 30
|
|
trial_end = now + timedelta(days=plan.trial_days) if plan.trial_days else None
|
|
|
|
subscription = Subscription.objects.create(
|
|
user=user,
|
|
plan=plan,
|
|
gateway=gateway,
|
|
external_id=external_id,
|
|
status=(SubscriptionStatus.TRIALING if trial_end and trial_end > now else SubscriptionStatus.ACTIVE),
|
|
current_period_start=now,
|
|
current_period_end=now + timedelta(days=interval_days),
|
|
trial_end=trial_end,
|
|
)
|
|
|
|
subscription_created.send(
|
|
sender=Subscription,
|
|
user=user,
|
|
plan_slug=plan.slug,
|
|
gateway=gateway.slug if gateway else "",
|
|
external_id=external_id,
|
|
)
|
|
return subscription
|
|
|
|
def cancel_subscription(self, subscription: Subscription) -> bool:
|
|
"""Cancels a subscription in the gateway and locally."""
|
|
gateway = subscription.gateway
|
|
if gateway:
|
|
try:
|
|
gateway_instance = self._get_gateway(gateway)
|
|
gateway_instance.cancel_subscription(subscription)
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("Gateway cancellation failed for subscription %s", subscription.pk)
|
|
|
|
subscription.status = SubscriptionStatus.CANCELLED
|
|
subscription.cancelled_at = timezone.now()
|
|
subscription.cancel_at_period_end = False
|
|
subscription.save()
|
|
|
|
subscription_cancelled.send(
|
|
sender=Subscription,
|
|
user=subscription.user,
|
|
plan_slug=subscription.plan.slug if subscription.plan else "",
|
|
reason="user_requested",
|
|
)
|
|
return True
|
|
|
|
def sync_subscription(self, subscription: Subscription) -> dict:
|
|
"""Syncs subscription state from the gateway into the local record."""
|
|
gateway = subscription.gateway
|
|
if gateway is None:
|
|
return {}
|
|
gateway_instance = self._get_gateway(gateway)
|
|
data = gateway_instance.sync_subscription(subscription)
|
|
for field in (
|
|
"status",
|
|
"current_period_start",
|
|
"current_period_end",
|
|
"cancel_at_period_end",
|
|
"cancelled_at",
|
|
"trial_end",
|
|
"metadata",
|
|
):
|
|
if field in data and data[field] is not None:
|
|
setattr(subscription, field, data[field])
|
|
subscription.save()
|
|
return data
|
|
|
|
def generate_invoice(
|
|
self,
|
|
subscription: Subscription,
|
|
amount=None,
|
|
line_items: list | None = None,
|
|
) -> Invoice:
|
|
"""Creates an invoice record and triggers PDF generation."""
|
|
plan = subscription.plan
|
|
currency = plan.price_currency if plan else get_setting("INFRASYNTH_BILLING", "DEFAULT_CURRENCY", "USD")
|
|
if amount is None:
|
|
amount = plan.price_amount if plan else 0
|
|
amount = Decimal(str(amount))
|
|
|
|
tax_percentage = get_setting("INFRASYNTH_BILLING", "TAX_PERCENTAGE", 0)
|
|
tax_name = get_setting("INFRASYNTH_BILLING", "TAX_NAME", "")
|
|
tax_amount = amount * (Decimal(tax_percentage) / Decimal(100))
|
|
|
|
invoice = Invoice.objects.create(
|
|
subscription=subscription,
|
|
user=subscription.user,
|
|
gateway=subscription.gateway,
|
|
invoice_number=self._next_invoice_number(),
|
|
amount=amount,
|
|
currency=currency,
|
|
tax_amount=tax_amount,
|
|
tax_name=tax_name,
|
|
status=InvoiceStatus.OPEN,
|
|
due_date=timezone.now() + timedelta(days=get_setting("INFRASYNTH_BILLING", "GRACE_PERIOD_DAYS", 5)),
|
|
line_items=line_items
|
|
or [
|
|
{
|
|
"description": plan.name if plan else "Subscription",
|
|
"amount": float(amount),
|
|
"quantity": 1,
|
|
}
|
|
],
|
|
)
|
|
|
|
invoice_generated.send(
|
|
sender=Invoice,
|
|
user=subscription.user,
|
|
invoice_id=invoice.id,
|
|
amount=float(invoice.amount),
|
|
)
|
|
|
|
from .invoice_generator import generate_invoice_pdf
|
|
|
|
generate_invoice_pdf.delay(invoice.id)
|
|
return invoice
|
|
|
|
def _next_invoice_number(self) -> str:
|
|
prefix = get_setting("INFRASYNTH_BILLING", "INVOICE_NUMBER_PREFIX", "INV-")
|
|
year = timezone.now().year
|
|
full_prefix = f"{prefix}{year}-"
|
|
count = Invoice.objects.filter(invoice_number__startswith=full_prefix).count()
|
|
return f"{full_prefix}{count + 1:06d}"
|
|
|
|
def _get_gateway(self, gateway: PaymentGateway):
|
|
gateway_cls = import_string(gateway.gateway_class)
|
|
return gateway_cls(gateway.config)
|