This commit is contained in:
2026-10-23 12:19:40 -04:00
parent 6cf0f0525f
commit faf6c5f2b3
19 changed files with 785 additions and 12 deletions
+67 -4
View File
@@ -12,7 +12,8 @@ from django.conf import settings
from django.core.mail import send_mail
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.shortcuts import get_object_or_404, redirect, render
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import salted_hmac
@@ -20,9 +21,9 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from . import billing, directory
from . import billing, directory, shkeeper
from .forms import PasswordForm, RegistrationForm
from .models import Account, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
from .models import Account, CryptoInvoice, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
logger = logging.getLogger(__name__)
@@ -69,7 +70,8 @@ def remember_groups(account):
def home(request):
account = Account.objects.filter(pk=request.session.get("account_id")).first() if request.session.get("account_id") else None
subscriptions = Subscription.objects.filter(account=account) if account else []
return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, now=timezone.now())
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
@require_http_methods(["GET", "POST"])
@@ -224,6 +226,8 @@ def checkout(request, tier):
try:
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=request.member.pk)
if CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=timezone.now())).exists():
return page(request, title="Prepaid membership already exists", message="Finish your SHKeeper invoice or wait for prepaid membership to expire before starting a recurring Stripe subscription.")
api = billing.client()
if not account.stripe_customer:
customer = api.customers.create({"email": account.email, "metadata": {"membership_account": str(account.pk)}}, options={"idempotency_key": f"member-customer-{account.pk}"})
@@ -287,3 +291,62 @@ def webhook(request):
except (ValueError, KeyError, TypeError, stripe.SignatureVerificationError):
return HttpResponse(status=400)
return HttpResponse(status=200)
@require_http_methods(["GET", "POST"])
@account_required
def crypto_checkout(request, tier):
config = settings.MEMBERSHIP_TIERS.get(tier)
if not settings.SHKEEPER_ENABLED or not config or not config.get("usd_amount"):
return HttpResponse(status=404)
if request.method == "GET":
return render(request, "crypto_checkout.html", {"tier": config, "tier_key": tier, "currencies": settings.SHKEEPER_CURRENCIES})
crypto = request.POST.get("crypto", "")
if crypto not in settings.SHKEEPER_CURRENCIES:
return HttpResponse("Choose an available cryptocurrency.", status=400)
if limited("crypto-checkout", str(request.member.pk), 20):
return HttpResponse(status=429)
try:
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=request.member.pk)
invoice = CryptoInvoice.objects.filter(account=account, credited_at__isnull=True).first()
if invoice:
# Reuse the existing asset/tier even if a stale tab asks for a
# different one. Never orphan an address a user may have paid.
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
if account.stripe_customer:
api = billing.client()
subs = api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100})
if any(sub.get("status") not in {"canceled", "incomplete_expired"} for sub in subs.auto_paging_iter()):
return page(request, title="Recurring membership already exists", message="Use Stripe billing to cancel renewal and wait until that subscription ends before paying through SHKeeper. This avoids double payments.")
if account.checkout_id:
checkout_session = api.checkout.sessions.retrieve(account.checkout_id)
if checkout_session.get("status") == "open":
return page(request, title="Stripe checkout is still open", message="Finish or let the existing Stripe Checkout expire before changing payment providers.")
if CryptoInvoice.objects.filter(account=account, access_end__gt=timezone.now()).exclude(tier=tier).exists():
return page(request, title="Another prepaid tier is active", message="Renew the same tier, or wait for the current prepaid membership to expire before changing tiers.")
invoice = CryptoInvoice.objects.create(account=account, tier=tier, crypto=crypto, usd_amount=config["usd_amount"])
remember_groups(account)
# Reserve/commit UUID BEFORE API call, so retries and worker recovery
# use the same external ID if the process dies after invoice creation.
shkeeper.prepare_invoice(account.pk, invoice.pk)
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
except Exception as exc:
logger.error("SHKeeper checkout failed account=%s kind=%s", request.member.pk, type(exc).__name__)
return page(request, title="Crypto checkout unavailable", message="Please try again later. If an invoice was reserved, it remains on your membership page and will be retried automatically. Do not send funds until payment details appear.")
@require_GET
@account_required
def crypto_invoice(request, invoice_id):
invoice = get_object_or_404(CryptoInvoice, pk=invoice_id, account=request.member)
return render(request, "crypto_invoice.html", {"invoice": invoice, "now": timezone.now()})
@csrf_exempt
@require_POST
def shkeeper_callback(request):
# SHKeeper documents no callback signature. Treat ALL incoming payloads as
# untrusted hints: do not parse them, write billing data, trigger network
# requests or grant access. The durable worker polls known invoice UUIDs.
return HttpResponse(status=200 if settings.SHKEEPER_ENABLED else 503)