"""Canonical provider reads, not callback 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, CryptoInvoice, ManagedGroup, Subscription, WebhookEvent from . import shkeeper from .service_groups import configured_groups 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() membership_level = 0 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"]) membership_level = max(membership_level, tier.get("level", 0)) for invoice in CryptoInvoice.objects.filter(account=account, status__in=["PAID", "OVERPAID"], access_start__lte=now, access_end__gt=now): tier = settings.MEMBERSHIP_TIERS.get(invoice.tier) if tier: desired.add(tier["group_dn"]) membership_level = max(membership_level, tier.get("level", 0)) # Compute from paid time on this pass, never the cached account level or # Dex claims. Level 0 is selected only when no paid entitlement remains. desired.update(settings.MEMBERSHIP_SERVICE_GROUPS.get(str(membership_level), [])) 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.membership_level = membership_level account.save(update_fields=["ldap_dn", "ldap_uuid", "last_ldap_sync", "membership_level"]) return len(desired) def sync_account(account_id, refresh_stripe=True): """Serialize Stripe/SHKeeper/LDAP writes 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. The existing refresh_stripe=False option means a local-only sweep: neither provider is contacted, so expiry remains independent of remote availability. """ # 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 dn in configured_groups(settings.MEMBERSHIP_TIERS, settings.MEMBERSHIP_SERVICE_GROUPS): ManagedGroup.objects.get_or_create(account=account, dn=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__) crypto_ok = shkeeper.refresh_account(account) 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 and crypto_ok