"""Canonical Stripe reads, not event arrival order, determine entitlements.""" import logging from datetime import datetime, timezone as dt_timezone import stripe from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import transaction from django.utils import timezone from .directory import reconcile_groups from .models import Account, ManagedGroup, Subscription, WebhookEvent logger = logging.getLogger(__name__) def client(): if not settings.STRIPE_SECRET_KEY: raise ImproperlyConfigured("STRIPE_SECRET_KEY is required.") return stripe.StripeClient(settings.STRIPE_SECRET_KEY, stripe_version=settings.STRIPE_API_VERSION, max_network_retries=1, http_client=stripe.RequestsClient(timeout=10)).v1 def _id(value): return value.get("id") if isinstance(value, dict) else value def _paid_until(api, subscription, price_id, now): """Recover paid time after outages, including when the latest invoice is open. Only paid recurring lines for the CURRENT price and subscription count. Positive prorations can grant an upgrade; credit lines cannot grant access. Pagination is essential: invoice headers' period_end is NOT the service end. """ end = None invoices = api.invoices.list({"subscription": subscription["id"], "status": "paid", "limit": 100}) for invoice in invoices.auto_paging_iter(): parent = (invoice.get("parent") or {}).get("subscription_details") or {} if _id(invoice.get("customer")) != _id(subscription["customer"]) or _id(parent.get("subscription")) != subscription["id"] or invoice.get("livemode") != settings.STRIPE_LIVE_MODE: continue lines = api.invoices.line_items.list(invoice["id"], {"limit": 100}) for line in lines.auto_paging_iter(): details = (line.get("parent") or {}).get("subscription_item_details") or {} price = ((line.get("pricing") or {}).get("price_details") or {}).get("price") period = line.get("period") or {} if _id(price) != price_id or _id(details.get("subscription")) != subscription["id"] or line.get("amount", -1) < 0: continue start, finish = period.get("start"), period.get("end") if not isinstance(start, int) or not isinstance(finish, int) or start > now.timestamp() or finish <= now.timestamp(): continue candidate = datetime.fromtimestamp(finish, dt_timezone.utc) end = max(end, candidate) if end else candidate # No old invoice can provide future service after its successor's period. # Keep scanning for unusual billing/proration histories; never guess. return end def refresh_billing(account): """Caller holds the account row lock. Commit only a complete Stripe snapshot.""" if not account.stripe_customer: return api = client() now = timezone.now() snapshots = [] prices = {tier["price_id"]: key for key, tier in settings.MEMBERSHIP_TIERS.items()} for sub in api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100}).auto_paging_iter(): if _id(sub.get("customer")) != account.stripe_customer or sub.get("livemode") != settings.STRIPE_LIVE_MODE or (sub.get("metadata") or {}).get("membership_account") != str(account.pk): continue items = sub.get("items") or {} data = items.get("data") or [] price_id = _id(data[0].get("price")) if len(data) == 1 and not items.get("has_more") else None tier = prices.get(price_id, "") eligible = tier and sub.get("status") in {"active", "past_due"} and not sub.get("pause_collection") paid_until = _paid_until(api, sub, price_id, now) if eligible else None if paid_until and sub.get("cancel_at"): paid_until = min(paid_until, datetime.fromtimestamp(sub["cancel_at"], dt_timezone.utc)) snapshots.append((sub["id"], {"tier": tier, "status": sub["status"], "paid_until": paid_until, "cancel_at_period_end": bool(sub.get("cancel_at_period_end"))})) # Do not partially overwrite paid time if a later Stripe page/read fails. with transaction.atomic(): seen = [] for stripe_id, values in snapshots: Subscription.objects.update_or_create(stripe_id=stripe_id, account=account, defaults=values) seen.append(stripe_id) Subscription.objects.filter(account=account).exclude(stripe_id__in=seen).update(status="missing", paid_until=None) account.last_billing_sync = now account.save(update_fields=["last_billing_sync"]) def apply_groups(account): now = timezone.now() desired = set() for sub in Subscription.objects.filter(account=account, status__in=["active", "past_due"], paid_until__gt=now): tier = settings.MEMBERSHIP_TIERS.get(sub.tier) if tier: desired.add(tier["group_dn"]) managed = set(ManagedGroup.objects.filter(account=account).values_list("dn", flat=True)) account.ldap_dn = reconcile_groups(account, desired, managed) if managed else account.ldap_dn account.last_ldap_sync = now account.save(update_fields=["ldap_dn", "last_ldap_sync"]) return len(desired) def sync_account(account_id, refresh_stripe=True): """Serialize Stripe/LDAP writes per account across web and worker processes. Local expiry runs even when Stripe is unreachable. LDAP failure is retried on the next pass, with persisted managed DNs allowing crash-safe revocation. """ # Commit management intent independently before external side effects. If # LDAP succeeds but a later DB commit fails, the next sweep still knows # which old groups to revoke, even after the tier is removed from config. with transaction.atomic(): account = Account.objects.select_for_update().get(pk=account_id) for tier in settings.MEMBERSHIP_TIERS.values(): ManagedGroup.objects.get_or_create(account=account, dn=tier["group_dn"]) with transaction.atomic(): account = Account.objects.select_for_update().get(pk=account_id) # Never hold local expiry hostage to an invoice-history API read. count = apply_groups(account) if not refresh_stripe: return True billing_ok = True try: refresh_billing(account) except Exception as exc: billing_ok = False logger.error("Billing refresh failed account=%s kind=%s", account.pk, type(exc).__name__) now = timezone.now() count = apply_groups(account) if billing_ok: WebhookEvent.objects.filter(account=account, processed_at__isnull=True).update(processed_at=now) logger.info("Membership reconciled account=%s groups=%s billing_ok=%s", account.pk, count, billing_ok) return billing_ok