150 lines
7.9 KiB
Python
Executable File
150 lines
7.9 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
|
|
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
|