Files
www/membership/members/billing.py
T
2026-10-23 12:02:50 -04:00

134 lines
6.8 KiB
Python
Executable File

"""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