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

142 lines
7.4 KiB
Python
Executable File

"""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
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"])
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"])
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/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 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__)
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