SHKeeper
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user