infrasynth-backend-kit/infrasynth/billing/services.py
2026-08-28 14:38:47 -05:00

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)