SHKeeper
This commit is contained in:
@@ -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
@@ -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")]}),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
Executable
+152
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Executable
+261
@@ -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))
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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