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
+12 -4
View File
@@ -1,4 +1,4 @@
"""Canonical Stripe reads, not event arrival order, determine entitlements."""
"""Canonical provider reads, not callback arrival order, determine entitlements."""
import logging
from datetime import datetime, timezone as dt_timezone
@@ -10,7 +10,8 @@ from django.db import transaction
from django.utils import timezone
from .directory import reconcile_groups
from .models import Account, ManagedGroup, Subscription, WebhookEvent
from .models import Account, CryptoInvoice, ManagedGroup, Subscription, WebhookEvent
from . import shkeeper
logger = logging.getLogger(__name__)
@@ -93,6 +94,10 @@ def apply_groups(account):
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
@@ -101,10 +106,12 @@ def apply_groups(account):
def sync_account(account_id, refresh_stripe=True):
"""Serialize Stripe/LDAP writes per account across web and worker processes.
"""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
@@ -125,9 +132,10 @@ def sync_account(account_id, refresh_stripe=True):
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
return billing_ok and crypto_ok
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Reconcile Stripe subscriptions and LDAP groups, including offline expiry."
help = "Reconcile Stripe/SHKeeper payments and LDAP groups, including offline expiry."
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true")
+27
View File
@@ -0,0 +1,27 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("members", "0001_initial")]
operations = [
migrations.AddField(model_name="account", name="last_shkeeper_sync", field=models.DateTimeField(null=True)),
migrations.CreateModel(name="CryptoInvoice", fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("tier", models.CharField(max_length=48)),
("usd_amount", models.DecimalField(max_digits=12, decimal_places=2)),
("crypto", models.CharField(max_length=32)),
("provider_id", models.CharField(blank=True, max_length=128)),
("wallet", models.CharField(blank=True, max_length=512)),
("crypto_amount", models.CharField(blank=True, max_length=128)),
("status", models.CharField(default="creating", max_length=24)),
("credited_at", models.DateTimeField(null=True)),
("access_start", models.DateTimeField(null=True)),
("access_end", models.DateTimeField(db_index=True, null=True)),
("last_checked_at", models.DateTimeField(null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("account", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="members.account")),
], options={"constraints": [models.UniqueConstraint(condition=models.Q(credited_at__isnull=True), fields=("account",), name="one_uncredited_crypto_invoice")]}),
]
+25
View File
@@ -14,6 +14,7 @@ class Account(models.Model):
checkout_id = models.CharField(max_length=128, blank=True)
checkout_key = models.UUIDField(default=uuid.uuid4)
last_billing_sync = models.DateTimeField(null=True)
last_shkeeper_sync = models.DateTimeField(null=True)
last_ldap_sync = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -50,6 +51,30 @@ class ManagedGroup(models.Model):
constraints = [models.UniqueConstraint(fields=["account", "dn"], name="unique_account_group")]
class CryptoInvoice(models.Model):
"""Immutable prepaid order; one canonical invoice can grant one month only."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
account = models.ForeignKey(Account, on_delete=models.PROTECT)
tier = models.CharField(max_length=48)
usd_amount = models.DecimalField(max_digits=12, decimal_places=2)
crypto = models.CharField(max_length=32)
provider_id = models.CharField(max_length=128, blank=True)
wallet = models.CharField(max_length=512, blank=True)
crypto_amount = models.CharField(max_length=128, blank=True)
# SHKeeper invoices do not expire. Keep incomplete orders available for late
# payments; never silently discard a deposit address with money on the way.
status = models.CharField(max_length=24, default="creating")
credited_at = models.DateTimeField(null=True)
access_start = models.DateTimeField(null=True)
access_end = models.DateTimeField(null=True, db_index=True)
last_checked_at = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["account"], condition=models.Q(credited_at__isnull=True), name="one_uncredited_crypto_invoice")]
class WebhookEvent(models.Model):
stripe_id = models.CharField(max_length=128, primary_key=True)
account = models.ForeignKey(Account, on_delete=models.CASCADE, null=True)
+152
View File
@@ -0,0 +1,152 @@
"""SHKeeper API 2.1 invoice integration. No custody, payouts or callback trust."""
import calendar
import json
import logging
from decimal import Decimal, InvalidOperation
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from .models import Account, CryptoInvoice
logger = logging.getLogger(__name__)
def request(method, path, payload=None):
if not settings.SHKEEPER_ENABLED:
raise ImproperlyConfigured("Configure SHKEEPER_URL, API key and allowed cryptocurrencies.")
# Host/path never come from a callback or user URL. No redirect forwarding
# of API credentials; TLS verification cannot be disabled, including LANs.
with requests.request(method, settings.SHKEEPER_URL + path, json=payload, headers={"X-Shkeeper-Api-Key": settings.SHKEEPER_API_KEY}, timeout=(5, 15), allow_redirects=False, verify=settings.SHKEEPER_CA_CERT_FILE or True, stream=True) as response:
if response.status_code != 200:
raise ValueError("SHKeeper API did not return success.")
content = bytearray()
for chunk in response.iter_content(16384):
content.extend(chunk)
if len(content) > 1048576:
raise ValueError("SHKeeper response exceeds the allowed size.")
data = json.loads(content)
if not isinstance(data, dict) or data.get("status") != "success":
raise ValueError("SHKeeper returned an invalid response.")
return data
def positive_decimal(value):
if not isinstance(value, (str, int)) or isinstance(value, bool) or len(str(value)) > 128:
raise ValueError("Invalid SHKeeper amount.")
try:
result = Decimal(value)
except InvalidOperation:
raise ValueError("Invalid SHKeeper amount.") from None
if not result.is_finite() or result <= 0:
raise ValueError("SHKeeper amounts must be finite and positive.")
return result
def external_id(invoice):
return f"www-membership-{invoice.pk}"
def ensure_created(invoice):
"""Stable external ID recovers timeouts without inventing another order.
SHKeeper's create/update API reuses the external ID. Always reuse the exact
original USD amount and asset; never recalculate an order after creation.
"""
if invoice.provider_id:
return
data = request("POST", f"/api/v1/{invoice.crypto}/payment_request", {
"external_id": external_id(invoice), "fiat": "USD", "amount": str(invoice.usd_amount),
"callback_url": settings.PUBLIC_URL + reverse("members:shkeeper_callback"),
})
provider_id = str(data.get("id", ""))
wallet = data.get("wallet")
amount = data.get("amount")
positive_decimal(amount)
if not provider_id or len(provider_id) > 128 or not isinstance(wallet, str) or not wallet or len(wallet) > 512:
raise ValueError("Invalid SHKeeper invoice address or identifier.")
invoice.provider_id = provider_id
invoice.wallet = wallet
invoice.crypto_amount = str(amount)
invoice.status = "UNPAID"
invoice.save(update_fields=["provider_id", "wallet", "crypto_amount", "status"])
def add_month(start):
year, month = (start.year + 1, 1) if start.month == 12 else (start.year, start.month + 1)
return start.replace(year=year, month=month, day=min(start.day, calendar.monthrange(year, month)[1]))
def refresh_invoice(invoice):
ensure_created(invoice)
data = request("GET", f"/api/v1/invoices/{external_id(invoice)}")
invoices = data.get("invoices")
# This app creates exactly one asset invoice for each UUID. Ambiguous
# responses/multi-asset payments require review, not guessed attribution.
if not isinstance(invoices, list) or len(invoices) != 1 or not isinstance(invoices[0], dict):
raise ValueError("Missing or ambiguous SHKeeper invoice.")
remote = invoices[0]
if str(remote.get("external_id")) != external_id(invoice) or remote.get("fiat") != "USD":
raise ValueError("SHKeeper invoice identity/currency mismatch.")
amount = positive_decimal(remote.get("amount_fiat"))
# SHKeeper may add configured merchant fees, but cannot lower our price.
if amount < positive_decimal(str(invoice.usd_amount)):
raise ValueError("SHKeeper invoice is below the stored membership price.")
status = remote.get("status")
if status not in {"UNPAID", "PARTIAL", "PAID", "OVERPAID"}:
raise ValueError("Unknown SHKeeper invoice status.")
paid = False
if status in {"PAID", "OVERPAID"}:
balance = positive_decimal(remote.get("balance_fiat"))
txs = remote.get("txs")
paid = balance >= amount and isinstance(txs, list) and bool(txs) and all(
isinstance(tx, dict) and tx.get("status") == "CONFIRMED"
and tx.get("crypto") == invoice.crypto and tx.get("addr") == invoice.wallet
and isinstance(tx.get("txid"), str) and bool(tx["txid"])
for tx in txs
)
now = timezone.now()
if paid and invoice.credited_at is None:
# Caller owns the account row lock, so concurrent confirmations cannot
# double-credit or allocate overlapping same-tier renewal periods.
previous = CryptoInvoice.objects.filter(account=invoice.account, tier=invoice.tier, credited_at__isnull=False).exclude(pk=invoice.pk).order_by("-access_end").first()
start = max(now, previous.access_end) if previous and previous.access_end else now
invoice.credited_at = now
invoice.access_start = start
invoice.access_end = add_month(start)
invoice.status = status if paid or status in {"UNPAID", "PARTIAL"} else "UNCONFIRMED"
invoice.last_checked_at = now
invoice.save(update_fields=["status", "credited_at", "access_start", "access_end", "last_checked_at"])
def refresh_account(account):
if not settings.SHKEEPER_ENABLED:
return True
okay = True
now = timezone.now()
for invoice in CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=now)).order_by("created_at"):
try:
# Isolate a failed provider read/DB write without undoing other paid
# orders. No partial values from an invalid response are persisted.
with transaction.atomic():
refresh_invoice(invoice)
except Exception as exc:
okay = False
logger.error("SHKeeper refresh failed invoice=%s kind=%s", invoice.pk, type(exc).__name__)
if okay:
account.last_shkeeper_sync = now
account.save(update_fields=["last_shkeeper_sync"])
return okay
def prepare_invoice(account_id, invoice_id):
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=account_id)
invoice = CryptoInvoice.objects.get(pk=invoice_id, account=account)
ensure_created(invoice)
+25
View File
@@ -8,6 +8,7 @@ from django.test import TransactionTestCase, override_settings
from members.billing import sync_account
from members.models import Account, ManagedGroup
from members.models import CryptoInvoice
@skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL row locks")
@@ -53,3 +54,27 @@ class PostgreSQLLockTests(TransactionTestCase):
# A later failed external side effect rolls back its own transaction,
# not the previously committed record of the managed group.
self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org")
@override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}})
def test_concurrent_crypto_verification_credits_one_month(self):
invoice = CryptoInvoice.objects.create(account=self.account, tier="test", usd_amount="5.00", crypto="BTC", provider_id="61", wallet="test-wallet", crypto_amount="0.0001", status="UNPAID")
data = {"status": "success", "invoices": [{"external_id": f"www-membership-{invoice.pk}", "fiat": "USD", "amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID", "txs": [{"addr": "test-wallet", "crypto": "BTC", "status": "CONFIRMED", "txid": "tx-confirmed", "amount": "0.0001"}]}]}
def verify():
close_old_connections()
try:
return sync_account(self.account.pk)
finally:
close_old_connections()
from members.shkeeper import add_month
with patch("members.shkeeper.request", return_value=data), patch("members.billing.reconcile_groups", return_value=self.account.ldap_dn):
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: verify(), range(2)))
self.assertTrue(all(results))
invoice.refresh_from_db()
self.assertIsNotNone(invoice.credited_at)
assert invoice.access_start is not None
self.assertEqual(invoice.access_end, add_month(invoice.access_start))
self.assertEqual(CryptoInvoice.objects.count(), 1)
+261
View File
@@ -0,0 +1,261 @@
import json
from datetime import datetime, timedelta, timezone as dt_timezone
from decimal import Decimal
from unittest.mock import patch
from django.conf import settings
from django.db import IntegrityError, transaction
from django.test import Client, SimpleTestCase, TestCase, override_settings
from django.utils import timezone
from members import billing, shkeeper
from members.models import Account, CryptoInvoice, ManagedGroup, Subscription
from .test_membership import DN, GROUP, OTHER_GROUP, TIERS, account, paged
CRYPTO_TIERS = {key: {**value, "usd_amount": "5.00" if key == "supporter" else "15.00"} for key, value in TIERS.items()}
SHKEEPER_SETTINGS = {
"SHKEEPER_ENABLED": True, "SHKEEPER_URL": "https://pay.example.org",
"SHKEEPER_API_KEY": "test-only-key", "SHKEEPER_CA_CERT_FILE": "",
"SHKEEPER_CURRENCIES": ["BTC", "XMR"], "MEMBERSHIP_TIERS": CRYPTO_TIERS,
}
@override_settings(**SHKEEPER_SETTINGS)
class SHKeeperTests(TestCase):
def setUp(self):
self.member = account()
session = self.client.session
session["account_id"] = str(self.member.pk)
session.save()
def invoice(self, **kwargs):
return CryptoInvoice.objects.create(account=self.member, tier="supporter", usd_amount="5.00", crypto="BTC", **kwargs)
def creation(self):
return {"status": "success", "id": 61, "wallet": "bc1-test-wallet", "amount": "0.0001", "recalculate_after": 0}
def remote(self, invoice, **changes):
return {"status": "success", "invoices": [{
"external_id": shkeeper.external_id(invoice), "fiat": "USD",
"amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID",
"txs": [{"status": "CONFIRMED", "addr": "bc1-test-wallet", "crypto": "BTC", "amount": "0.0001", "txid": "confirmed-tx"}], **changes,
}]}
def prepared(self):
return self.invoice(provider_id="61", wallet="bc1-test-wallet", crypto_amount="0.0001", status="UNPAID")
def login_other(self):
other = Account.objects.create(uid="bob", email="bob@example.org", ldap_dn="uid=bob,dc=example", ldap_uuid="uuid-bob")
session = self.client.session
session["account_id"] = str(other.pk)
session.save()
def test_crypto_buttons_only_when_configured(self):
self.assertContains(self.client.get("/members/"), "Pay with crypto", count=2)
with override_settings(SHKEEPER_ENABLED=False):
self.assertNotContains(self.client.get("/members/"), "Pay with crypto")
self.assertEqual(self.client.get("/members/crypto/checkout/supporter/").status_code, 404)
def test_checkout_form_csrf_login_and_currency_allowlist(self):
self.assertContains(self.client.get("/members/crypto/checkout/supporter/"), "not an automatically renewing")
self.assertEqual(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "../../evil"}).status_code, 400)
self.assertFalse(CryptoInvoice.objects.exists())
strict = Client(enforce_csrf_checks=True)
self.assertEqual(strict.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}).status_code, 403)
self.client.post("/members/logout/")
self.assertRedirects(self.client.get("/members/crypto/checkout/supporter/"), "/members/")
@patch("members.shkeeper.request")
def test_checkout_uses_server_price_and_stable_uuid(self, request):
request.return_value = self.creation()
response = self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC", "usd_amount": "0.01", "account": "victim"})
invoice = CryptoInvoice.objects.get()
self.assertRedirects(response, f"/members/crypto/invoice/{invoice.pk}/")
self.assertEqual(invoice.usd_amount, Decimal("5.00"))
self.assertEqual(invoice.account, self.member)
method, path, params = request.call_args.args
self.assertEqual((method, path), ("POST", "/api/v1/BTC/payment_request"))
self.assertEqual(params["amount"], "5.00")
self.assertEqual(params["external_id"], shkeeper.external_id(invoice))
self.assertEqual(params["callback_url"], settings.PUBLIC_URL + "/members/shkeeper/callback/")
self.assertIsNone(invoice.credited_at)
self.assertEqual(ManagedGroup.objects.count(), 2)
# Another crypto/tier request reopens the previous immutable order.
self.assertRedirects(self.client.post("/members/crypto/checkout/patron/", {"crypto": "XMR"}), f"/members/crypto/invoice/{invoice.pk}/")
self.assertEqual(request.call_count, 1)
self.assertEqual(CryptoInvoice.objects.count(), 1)
@patch("members.shkeeper.request", side_effect=OSError("timeout"))
def test_creation_timeout_keeps_reserved_order_for_worker(self, request):
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "checkout unavailable")
invoice = CryptoInvoice.objects.get()
self.assertEqual(invoice.status, "creating")
first_id = request.call_args.args[2]["external_id"]
request.side_effect = None
request.return_value = self.creation()
shkeeper.prepare_invoice(self.member.pk, invoice.pk)
self.assertEqual(request.call_args.args[2]["external_id"], first_id)
def test_only_one_uncredited_order_constraint(self):
self.invoice()
with self.assertRaises(IntegrityError), transaction.atomic():
self.invoice()
def test_invoice_is_account_scoped_and_get_never_calls_provider(self):
invoice = self.prepared()
with patch("members.shkeeper.request") as request:
self.assertContains(self.client.get(f"/members/crypto/invoice/{invoice.pk}/"), "bc1-test-wallet")
request.assert_not_called()
self.login_other()
self.assertEqual(self.client.get(f"/members/crypto/invoice/{invoice.pk}/").status_code, 404)
@patch("members.shkeeper.request")
def test_spoofed_callback_cannot_grant_or_trigger_network(self, request):
invoice = self.prepared()
for _ in range(3):
self.assertEqual(self.client.post("/members/shkeeper/callback/", json.dumps({"external_id": shkeeper.external_id(invoice), "paid": True, "status": "PAID", "balance_fiat": "999999"}), content_type="application/json").status_code, 200)
request.assert_not_called()
invoice.refresh_from_db()
self.assertIsNone(invoice.credited_at)
self.assertEqual(invoice.status, "UNPAID")
self.assertEqual(self.client.get("/members/shkeeper/callback/").status_code, 405)
@patch("members.shkeeper.request")
def test_canonical_paid_once_repeated_callbacks_do_not_extend(self, request):
invoice = self.prepared()
request.return_value = self.remote(invoice)
with transaction.atomic():
shkeeper.refresh_invoice(invoice)
first_end = invoice.access_end
self.assertEqual(invoice.access_end, shkeeper.add_month(invoice.access_start))
shkeeper.refresh_invoice(invoice)
self.assertEqual(invoice.access_end, first_end)
self.assertEqual(invoice.status, "PAID")
@patch("members.shkeeper.request")
def test_partial_unpaid_unconfirmed_underpaid_cannot_grant(self, request):
invoice = self.prepared()
for changes in ({"status": "UNPAID", "balance_fiat": "0"}, {"status": "PARTIAL", "balance_fiat": "1"}, {"balance_fiat": "4.99"}, {"txs": []}, {"txs": [{"status": "UNCONFIRMED"}]}, {"txs": [{"status": "CONFIRMED", "addr": "wrong", "crypto": "BTC", "txid": "x"}]}):
request.return_value = self.remote(invoice, **changes)
shkeeper.refresh_invoice(invoice)
self.assertIsNone(invoice.credited_at)
@patch("members.shkeeper.request")
def test_mismatched_invalid_amount_or_ambiguous_data_rejected(self, request):
invoice = self.prepared()
for changes in ({"external_id": "another-order"}, {"fiat": "EUR"}, {"amount_fiat": "0.01"}, {"amount_fiat": "NaN"}, {"balance_fiat": "Infinity"}, {"status": "unknown"}):
request.return_value = self.remote(invoice, **changes)
with self.assertRaises(ValueError):
shkeeper.refresh_invoice(invoice)
data = self.remote(invoice)
data["invoices"] *= 2
request.return_value = data
with self.assertRaises(ValueError):
shkeeper.refresh_invoice(invoice)
invoice.refresh_from_db()
self.assertIsNone(invoice.credited_at)
@patch("members.shkeeper.request")
def test_renewal_stacks_calendar_months_and_overpayment_once(self, request):
first = self.prepared()
request.return_value = self.remote(first)
shkeeper.refresh_invoice(first)
second = self.prepared()
request.return_value = self.remote(second, status="OVERPAID", balance_fiat="50")
shkeeper.refresh_invoice(second)
self.assertEqual(second.access_start, first.access_end)
self.assertEqual(second.access_end, shkeeper.add_month(first.access_end))
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.shkeeper.request")
def test_paid_crypto_grant_expiry_and_outage(self, request, ldap):
invoice = self.prepared()
request.return_value = self.remote(invoice)
self.assertTrue(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP})
request.side_effect = OSError("SHKeeper offline")
self.assertFalse(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP})
CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1))
billing.sync_account(self.member.pk, refresh_stripe=False)
self.assertEqual(ldap.call_args.args[1], set())
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.shkeeper.request")
def test_reorg_revokes_and_recovery_does_not_restart_month(self, request, ldap):
invoice = self.prepared()
request.return_value = self.remote(invoice)
billing.sync_account(self.member.pk)
invoice.refresh_from_db()
original_end = invoice.access_end
request.return_value = self.remote(invoice, status="UNPAID", balance_fiat="0", txs=[])
billing.sync_account(self.member.pk)
self.assertEqual(ldap.call_args.args[1], set())
request.return_value = self.remote(invoice)
billing.sync_account(self.member.pk)
invoice.refresh_from_db()
self.assertEqual(invoice.access_end, original_end)
self.assertEqual(ldap.call_args.args[1], {GROUP})
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.billing.refresh_billing", side_effect=OSError("Stripe offline"))
@patch("members.shkeeper.request")
def test_stripe_failure_does_not_prevent_crypto_and_union(self, request, stripe, ldap):
invoice = self.prepared()
request.return_value = self.remote(invoice)
Subscription.objects.create(account=self.member, stripe_id="sub_other", tier="patron", status="active", paid_until=timezone.now() + timedelta(days=1))
self.assertFalse(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP})
with override_settings(MEMBERSHIP_TIERS={}):
billing.sync_account(self.member.pk, refresh_stripe=False)
self.assertEqual(ldap.call_args.args[1], set())
self.assertIn(GROUP, ldap.call_args.args[2])
@patch("members.shkeeper.request")
@patch("members.views.billing.client")
def test_active_stripe_and_open_checkout_block_crypto(self, stripe, request):
self.member.stripe_customer = "cus_alice"
self.member.save()
stripe.return_value.subscriptions.list.return_value = paged([{"status": "active"}])
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "Recurring membership already exists")
stripe.return_value.subscriptions.list.return_value = paged([])
Account.objects.filter(pk=self.member.pk).update(checkout_id="cs_open")
stripe.return_value.checkout.sessions.retrieve.return_value = {"status": "open"}
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "Stripe checkout is still open")
request.assert_not_called()
self.assertFalse(CryptoInvoice.objects.exists())
@patch("members.views.billing.client")
def test_unfinished_or_active_crypto_blocks_stripe(self, stripe):
invoice = self.prepared()
self.assertContains(self.client.post("/members/checkout/supporter/"), "Prepaid membership already exists")
stripe.assert_not_called()
CryptoInvoice.objects.filter(pk=invoice.pk).update(credited_at=timezone.now(), access_end=timezone.now() + timedelta(days=1))
self.assertContains(self.client.post("/members/crypto/checkout/patron/", {"crypto": "BTC"}), "Another prepaid tier is active")
@override_settings(**SHKEEPER_SETTINGS)
class SHKeeperTransportTests(SimpleTestCase):
@patch("members.shkeeper.requests.request")
def test_api_auth_tls_timeout_no_redirects_and_limits(self, request):
response = request.return_value.__enter__.return_value
response.status_code = 200
response.iter_content.return_value = [b'{"status":"success","invoices":[]}']
self.assertEqual(shkeeper.request("GET", "/api/v1/invoices/order")["invoices"], [])
self.assertEqual(request.call_args.args[:2], ("GET", "https://pay.example.org/api/v1/invoices/order"))
self.assertEqual(request.call_args.kwargs["headers"]["X-Shkeeper-Api-Key"], "test-only-key")
self.assertIs(request.call_args.kwargs["verify"], True)
self.assertFalse(request.call_args.kwargs["allow_redirects"])
self.assertEqual(request.call_args.kwargs["timeout"], (5, 15))
response.status_code = 302
with self.assertRaises(ValueError):
shkeeper.request("GET", "/api/v1/invoices/order")
response.status_code = 200
response.iter_content.return_value = [b"x" * 1048577]
with self.assertRaises(ValueError):
shkeeper.request("GET", "/api/v1/invoices/order")
def test_calendar_month_handles_leap_year_and_december(self):
january = datetime(2028, 1, 31, tzinfo=dt_timezone.utc)
self.assertEqual(shkeeper.add_month(january), datetime(2028, 2, 29, tzinfo=dt_timezone.utc))
self.assertEqual(shkeeper.add_month(datetime(2027, 12, 15, tzinfo=dt_timezone.utc)), datetime(2028, 1, 15, tzinfo=dt_timezone.utc))
+3
View File
@@ -14,4 +14,7 @@ urlpatterns = [
path("checkout/<slug:tier>/", views.checkout, name="checkout"),
path("billing/", views.portal, name="portal"),
path("stripe/webhook/", views.webhook, name="webhook"),
path("crypto/checkout/<slug:tier>/", views.crypto_checkout, name="crypto_checkout"),
path("crypto/invoice/<uuid:invoice_id>/", views.crypto_invoice, name="crypto_invoice"),
path("shkeeper/callback/", views.shkeeper_callback, name="shkeeper_callback"),
]
+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)