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)