From faf6c5f2b39d2161b4e48926ff940de7e4327c8d Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Fri, 23 Oct 2026 12:19:40 -0400 Subject: [PATCH] SHKeeper --- .env.example | 9 + .vscode/tasks.json | 1 + README.md | 107 ++++++- htdocs/static/membership.css | 4 + membership/config/settings.py | 19 ++ membership/members/billing.py | 16 +- .../management/commands/sync_memberships.py | 2 +- .../migrations/0002_crypto_invoices.py | 27 ++ membership/members/models.py | 25 ++ membership/members/shkeeper.py | 152 ++++++++++ membership/members/tests/test_postgres.py | 25 ++ membership/members/tests/test_shkeeper.py | 261 ++++++++++++++++++ membership/members/urls.py | 3 + membership/members/views.py | 71 ++++- membership/templates/crypto_base.html | 23 ++ membership/templates/crypto_checkout.html | 14 + membership/templates/crypto_invoice.html | 24 ++ membership/templates/members.html | 9 +- membership/tiers.eom.json | 5 + 19 files changed, 785 insertions(+), 12 deletions(-) create mode 100755 membership/members/migrations/0002_crypto_invoices.py create mode 100755 membership/members/shkeeper.py create mode 100755 membership/members/tests/test_shkeeper.py create mode 100755 membership/templates/crypto_base.html create mode 100755 membership/templates/crypto_checkout.html create mode 100755 membership/templates/crypto_invoice.html diff --git a/.env.example b/.env.example index e150d6a..0039863 100755 --- a/.env.example +++ b/.env.example @@ -36,6 +36,15 @@ STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_LIVE_MODE=1 STRIPE_PORTAL_CONFIGURATION= +# Optional SHKeeper prepaid crypto. Leave empty to hide/disable new invoices. +# HTTPS only; use your actual origin (not an API path or a user-supplied URL). +SHKEEPER_URL= +SHKEEPER_API_KEY= +# Comma-separated API crypto identifiers actually enabled on your instance: +# SHKEEPER_CURRENCIES=BTC,LTC,XMR,ETH-USDT +SHKEEPER_CURRENCIES= +# Mount a private CA read-only into web + worker if needed. Never disable TLS. +SHKEEPER_CA_CERT_FILE= # Set real recurring Stripe Price IDs and pre-created dedicated LDAP groups. # The display price is informational; Stripe Checkout is authoritative. MEMBERSHIP_TIERS_FILE=/app/tiers.eom.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2ad7e50..dcdc05b 100755 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,6 +11,7 @@ "members.tests.test_membership", "members.tests.test_oidc", "members.tests.test_postgres", + "members.tests.test_shkeeper", "--settings=config.test_settings", "--verbosity=1" ], diff --git a/README.md b/README.md index 4e792cb..e62a55e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ Apache serves the static eom.dev website. A separate Django service provides public accounts, Dex sign-in, and Stripe membership under **/members/**. +Optional SHKeeper support adds prepaid crypto membership alongside Stripe. +Both payment methods feed the same LDAP group reconciliation and local expiry. + ## Membership flow 1. Anyone can request a free account. A one-hour, single-use email link allows @@ -146,6 +149,103 @@ No production credentials or live account changes are made by the test suite. ## Operations and limitations +### SHKeeper prepaid crypto + +Stripe remains the automatically recurring payment method. SHKeeper accepts +**one-time payments for one calendar month** of the same tiers, at the USD +amounts in [membership/tiers.eom.json](membership/tiers.eom.json). It does not +debit a wallet or automatically renew. The user must create and pay a new +invoice for each month. Amounts are server-side configuration, never supplied by +the browser and never parsed from the display-price label. Keep `usd_amount` +aligned with your Stripe prices when changing prices; existing orders retain +the original amount. + +To enable: + +1. Set `SHKEEPER_URL` to your actual HTTPS origin, `SHKEEPER_API_KEY` to its + invoice API key, and `SHKEEPER_CURRENCIES` to a comma-separated allowlist of + the API currency identifiers enabled on **your** instance (e.g. BTC or XMR). + These identifiers are network-specific for tokens (e.g. ETH-USDT is not + interchangeable with TRX-USDT). No instance URL, wallets or keys are assumed. +2. For an internal CA, mount the trusted CA file read-only into both web and + worker containers and set `SHKEEPER_CA_CERT_FILE`. TLS verification is always + enabled. API requests have timeouts, disallow redirects, and cap response + size; the API key never reaches the browser. No payout/UI administrator + credentials or wallet private keys are needed. Restrict network/API access + because SHKeeper's API key may expose more than invoice endpoints. +3. Review SHKeeper fee, exchange-rate recalculation, payment-tolerance and + confirmation settings. This integration requires a full invoice amount and + confirmed transactions; it intentionally does not grant access for partial, + underpaid or unconfirmed transfers, even if a provider tolerance says paid. + The payment page identifies its initial quote as such; a partial/late + payment may require administrator assistance with the updated remainder. +4. Each new invoice supplies the callback URL + `https://eom.dev/members/shkeeper/callback/`. Permit SHKeeper to reach it. + **SHKeeper's documented callbacks have no signature.** This endpoint only + acknowledges them: it never trusts amounts/status/IDs, writes payment state, + or triggers an API request. The worker polls known local invoice UUIDs over + authenticated HTTPS, so forged, repeated, missing or reordered callbacks + cannot grant access or extend a term. +5. Redeploy the image and run the migration service, which applies + [membership/members/migrations/0002_crypto_invoices.py](membership/members/migrations/0002_crypto_invoices.py). + Keep the membership worker running; it independently refreshes Stripe and + SHKeeper, and performs a local-only expiry sweep before remote requests. +6. Smoke-test a low-value invoice on your actual deployment before offering + public crypto payments. The adapter follows the documented SHKeeper 2.1 API: + `POST /api/v1/{crypto}/payment_request` and + `GET /api/v1/invoices/{external_id}`. Require the GET response to include + `external_id`, `fiat`, `amount_fiat`, `balance_fiat`, `status` and `txs` + (transaction status, asset identifier, address and txid). Unsupported or + ambiguous responses do not grant access. Different SHKeeper versions must + be verified against these fields, especially transaction crypto identifiers. + +Payment lifecycle: + +- An authenticated user selects the currency, then sees a private invoice + with its deposit address and quote. One unfinished invoice is allowed per + account. Repeated submissions reopen that same invoice, even if a second + tab requests another tier or currency. The UUID and USD amount are committed + **before** contacting SHKeeper; retrying after a timeout reuses SHKeeper's + documented create/update-by-external-ID operation with the same parameters. +- There is no fabricated invoice deadline: SHKeeper deposit addresses can + receive late transfers. Unpaid/partial orders remain polled and cannot be + abandoned/switched from the UI. Contact the administrator if the wrong + network was selected; do not delete orders with potentially in-flight funds. +- Access begins when the worker first verifies a fully paid invoice with + matching USD order, amount, receiving address and asset and confirmed txs. + A renewal of the **same tier** starts at the later of now or that tier's + previous prepaid end and adds one calendar month in UTC. Month-end dates + clamp to the last day of the next month. Queued future terms do not grant + early access. Delayed worker verification starts a new term at verification, + not at an untrusted callback timestamp. +- Each invoice can grant **exactly one term**. Overpayments, repeated callbacks + and additional transfers to a paid address never buy extra months. Create a + new invoice for renewal. Refund/overpayment handling is manual; this app does + not issue cryptocurrency payouts. +- Active/queued paid crypto invoices are rechecked until their end. If an + invoice reverts to unpaid/partial/unconfirmed (e.g. a chain reorganization), + its grant is removed. Recovery restores only its original term, not another + month. During API outages, the last verified term remains valid only until + its stored expiry; local expiry never waits for provider recovery. Alert on + stale `last_shkeeper_sync`, failed polls and worker errors. +- LDAP desired groups are the **union** of all currently valid Stripe and + SHKeeper terms. One provider's failure/cancellation cannot revoke a still + valid grant from another. Removed tiers are revoked regardless of provider. +- To avoid accidental double payments, a current Stripe subscription or open + Stripe Checkout blocks a new crypto invoice. Unfinished or unexpired crypto + orders block a new Stripe subscription; another crypto tier must expire + before switching tiers. Same-tier crypto renewals remain available. This + does not cancel existing Stripe recurring charges: finish/cancel them through + Stripe's billing portal before changing methods. Provider-side/manual changes + can still create overlapping payments; grants remain a safe union. +- Leaving SHKeeper configuration empty hides new crypto checkouts. Previously + verified prepaid grants still expire locally, but remote status cannot be + rechecked while the provider is disabled. Do not delete payment history or + change your SHKeeper server/key to an unrelated instance while orders exist. + `CryptoInvoice` protects its parent Account from accidental deletion. + +### General operations + - Keep `membership-worker` running. Alert on its errors and on accounts whose `last_ldap_sync` or `last_billing_sync` is stale. A one-shot `sync_memberships` command exits nonzero on a failed sweep. Logs record account @@ -185,6 +285,11 @@ webhooks, paid invoice periods, cancellation, failed renewals, unavailable Strip LDAP retries, tier removal, multiple subscriptions, and the last member's removal from a `groupOfUniqueNames` using its sentinel. Live Stripe, Dex, SMTP and OpenLDAP must still be smoke-tested together in a non-production environment before -enabling public registration/payment. +enabling public registration/payment. SHKeeper regressions additionally cover +API-key/TLS transport, immutable prices and account-scoped invoices, callback +spoofing, partial/unconfirmed/underpaid transfers, duplicate confirmation, +calendar-month renewals, overpayments, reorg recovery, provider outages, and +cross-provider overlap. PostgreSQL also verifies that concurrent workers credit +one crypto invoice only once. diff --git a/htdocs/static/membership.css b/htdocs/static/membership.css index 5f6f27d..0957ed9 100755 --- a/htdocs/static/membership.css +++ b/htdocs/static/membership.css @@ -16,6 +16,10 @@ button.button { cursor: pointer; font-size: inherit; line-height: inherit; } .tier-card h3 { font-size: 1.8rem; } .tier-card ul { padding-left: 1.2rem; margin-bottom: 2rem; color: var(--muted); } .tier-card > form, .tier-card > .button, .tier-footnote { margin-top: auto; } +.tier-payment-actions { display: grid; gap: .8rem; margin-top: auto; } +.form-panel select { width: 100%; padding: .8rem 1rem; border-radius: .6rem; border: 1px solid var(--border); background: var(--page-deep); color: var(--text); font: inherit; } +.crypto-details dt { margin-top: 1rem; color: var(--muted); font-size: .9rem; } +.crypto-details dd { margin: .2rem 0; overflow-wrap: anywhere; } .tier-price { color: var(--text); font-size: 1.8rem; font-weight: 700; } .tier-price span { font-size: 1rem; font-weight: 400; color: var(--muted); } .tier-card .button { width: 100%; text-align: center; } diff --git a/membership/config/settings.py b/membership/config/settings.py index 8a888b6..47412d2 100755 --- a/membership/config/settings.py +++ b/membership/config/settings.py @@ -3,6 +3,7 @@ import json import os import re +from decimal import Decimal, InvalidOperation from pathlib import Path from urllib.parse import urlsplit @@ -97,6 +98,17 @@ STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") STRIPE_LIVE_MODE = os.environ.get("STRIPE_LIVE_MODE", "0") == "1" STRIPE_PORTAL_CONFIGURATION = os.environ.get("STRIPE_PORTAL_CONFIGURATION", "") STRIPE_API_VERSION = "2025-03-31.basil" +SHKEEPER_URL = os.environ.get("SHKEEPER_URL", "").rstrip("/") +SHKEEPER_API_KEY = os.environ.get("SHKEEPER_API_KEY", "") +SHKEEPER_CA_CERT_FILE = os.environ.get("SHKEEPER_CA_CERT_FILE", "") +SHKEEPER_CURRENCIES = [value.strip() for value in os.environ.get("SHKEEPER_CURRENCIES", "").split(",") if value.strip()] +SHKEEPER_ENABLED = bool(SHKEEPER_URL and SHKEEPER_API_KEY and SHKEEPER_CURRENCIES) +if SHKEEPER_URL: + shkeeper_origin = urlsplit(SHKEEPER_URL) + if shkeeper_origin.scheme != "https" or not shkeeper_origin.hostname or shkeeper_origin.username or shkeeper_origin.path or shkeeper_origin.query or shkeeper_origin.fragment: + raise ImproperlyConfigured("SHKEEPER_URL must be a certificate-verified HTTPS origin.") +if any(not re.fullmatch(r"[A-Z0-9][A-Z0-9-]{0,31}", value) for value in SHKEEPER_CURRENCIES): + raise ImproperlyConfigured("SHKEEPER_CURRENCIES must contain API crypto identifiers such as BTC or ETH-USDT.") tier_file = os.environ.get("MEMBERSHIP_TIERS_FILE", "") MEMBERSHIP_TIERS = json.loads(os.environ["MEMBERSHIP_TIERS_JSON"]) if "MEMBERSHIP_TIERS_JSON" in os.environ else json.loads(Path(tier_file).read_text(encoding="utf-8")) if tier_file else {} if not isinstance(MEMBERSHIP_TIERS, dict): @@ -111,3 +123,10 @@ for key, tier in MEMBERSHIP_TIERS.items(): if not tier["price_id"].startswith("price_") or tier["price_id"] in prices: raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.") prices.add(tier["price_id"]) + if "usd_amount" in tier: + try: + amount = Decimal(tier["usd_amount"]) + if not amount.is_finite() or amount <= 0 or amount > 1000000 or amount != amount.quantize(Decimal("0.01")): + raise ValueError + except (InvalidOperation, ValueError, TypeError): + raise ImproperlyConfigured(f"Tier {key} usd_amount must be a positive USD amount with at most two decimals.") from None diff --git a/membership/members/billing.py b/membership/members/billing.py index eb22875..b113ce9 100755 --- a/membership/members/billing.py +++ b/membership/members/billing.py @@ -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 diff --git a/membership/members/management/commands/sync_memberships.py b/membership/members/management/commands/sync_memberships.py index 97790d7..82df9e0 100755 --- a/membership/members/management/commands/sync_memberships.py +++ b/membership/members/management/commands/sync_memberships.py @@ -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") diff --git a/membership/members/migrations/0002_crypto_invoices.py b/membership/members/migrations/0002_crypto_invoices.py new file mode 100755 index 0000000..d96ce31 --- /dev/null +++ b/membership/members/migrations/0002_crypto_invoices.py @@ -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")]}), + ] \ No newline at end of file diff --git a/membership/members/models.py b/membership/members/models.py index 56ddcc4..549de0c 100755 --- a/membership/members/models.py +++ b/membership/members/models.py @@ -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) diff --git a/membership/members/shkeeper.py b/membership/members/shkeeper.py new file mode 100755 index 0000000..2bca932 --- /dev/null +++ b/membership/members/shkeeper.py @@ -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) \ No newline at end of file diff --git a/membership/members/tests/test_postgres.py b/membership/members/tests/test_postgres.py index ddef297..8a1fce9 100755 --- a/membership/members/tests/test_postgres.py +++ b/membership/members/tests/test_postgres.py @@ -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) diff --git a/membership/members/tests/test_shkeeper.py b/membership/members/tests/test_shkeeper.py new file mode 100755 index 0000000..1c1d69d --- /dev/null +++ b/membership/members/tests/test_shkeeper.py @@ -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)) \ No newline at end of file diff --git a/membership/members/urls.py b/membership/members/urls.py index 1b1bd38..dfbe9b1 100755 --- a/membership/members/urls.py +++ b/membership/members/urls.py @@ -14,4 +14,7 @@ urlpatterns = [ path("checkout//", views.checkout, name="checkout"), path("billing/", views.portal, name="portal"), path("stripe/webhook/", views.webhook, name="webhook"), + path("crypto/checkout//", views.crypto_checkout, name="crypto_checkout"), + path("crypto/invoice//", views.crypto_invoice, name="crypto_invoice"), + path("shkeeper/callback/", views.shkeeper_callback, name="shkeeper_callback"), ] diff --git a/membership/members/views.py b/membership/members/views.py index 54e6712..95796c2 100755 --- a/membership/members/views.py +++ b/membership/members/views.py @@ -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) diff --git a/membership/templates/crypto_base.html b/membership/templates/crypto_base.html new file mode 100755 index 0000000..2654f01 --- /dev/null +++ b/membership/templates/crypto_base.html @@ -0,0 +1,23 @@ + + + + + + + Crypto membership · eom.dev + + + + + +
eom.dev
+
+
+

Crypto payments · Powered by SHKeeper

+ {% block content %}{% endblock %} +

Back to membership

+
+
+ + + \ No newline at end of file diff --git a/membership/templates/crypto_checkout.html b/membership/templates/crypto_checkout.html new file mode 100755 index 0000000..56ec7e7 --- /dev/null +++ b/membership/templates/crypto_checkout.html @@ -0,0 +1,14 @@ +{% extends "crypto_base.html" %} +{% block content %} +

Prepay {{ tier.label }}

+

${{ tier.usd_amount }} USD / one calendar month

+

Pay once with cryptocurrency. This is not an automatically renewing subscription. Renewing the same tier adds one calendar month after your current prepaid term ends.

+
+ {% csrf_token %} +

+

+

Choose carefully. Tokens on different networks are not interchangeable. SHKeeper supplies the deposit address and crypto amount. Configured processor fees or exchange-rate changes may affect the required amount.

+ +
+

Only one unfinished crypto invoice is allowed per account. Repeated requests reopen it rather than create another deposit address. Stripe subscriptions must end before switching to crypto.

+{% endblock %} \ No newline at end of file diff --git a/membership/templates/crypto_invoice.html b/membership/templates/crypto_invoice.html new file mode 100755 index 0000000..57cf7ea --- /dev/null +++ b/membership/templates/crypto_invoice.html @@ -0,0 +1,24 @@ +{% extends "crypto_base.html" %} +{% block content %} +

{{ invoice.tier|title }} invoice

+

SHKeeper status: {{ invoice.status }}

+
+
Invoice reference
{{ invoice.id }}
+
Membership amount
${{ invoice.usd_amount }} USD · one calendar month
+
Cryptocurrency / network
{{ invoice.crypto }}
+ {% if invoice.wallet %}
Deposit address
{{ invoice.wallet }}
+
Initial crypto quote
{{ invoice.crypto_amount }} {{ invoice.crypto }}
{% endif %} + {% if invoice.last_checked_at %}
Last checked
{{ invoice.last_checked_at|date:"M j, Y H:i:s T" }}
{% endif %} +
+{% if invoice.credited_at %} +

One prepaid term has been recorded: {{ invoice.access_start|date:"M j, Y H:i T" }} through {{ invoice.access_end|date:"M j, Y H:i T" }}.

+

This invoice cannot buy another term, even if more coins arrive. Create a new invoice from Membership to renew. Access requires a confirmed paid status and is removed at expiry.

+{% elif invoice.wallet %} +

Send only {{ invoice.crypto }} on its correct network to this address. Wait for blockchain confirmations and automatic verification before expecting access.

+

The quote is from invoice creation, not a guaranteed permanent exchange rate. SHKeeper may recalculate after its configured rate-lock window. Partial payments do not activate membership. If a balance remains after your transfer, contact the administrator for the current remainder rather than repeatedly sending the initial quote.

+{% else %} +

The invoice is reserved and awaiting payment details from SHKeeper. The worker retries automatically. Do not send funds until an address appears.

+{% endif %} +

Refresh payment status

+

Refreshing this page only reads stored status. The worker checks SHKeeper automatically. Crypto payments are irreversible; do not reuse a paid address for renewal. Overpayments do not automatically buy extra months.

+{% endblock %} \ No newline at end of file diff --git a/membership/templates/members.html b/membership/templates/members.html index b821322..b646a5a 100755 --- a/membership/templates/members.html +++ b/membership/templates/members.html @@ -48,6 +48,9 @@ {% for subscription in subscriptions %}

{{ subscription.tier|default:"Membership"|title }} · {{ subscription.status }}{% if subscription.paid_until > now %} · Paid access through {{ subscription.paid_until|date:"M j, Y H:i T" }}{% else %} · No current paid access{% endif %}{% if subscription.cancel_at_period_end %} · Does not renew{% endif %}

{% endfor %} + {% for invoice in crypto_invoices %} +

SHKeeper · {{ invoice.tier|title }} · {{ invoice.status }}{% if invoice.access_end > now %} · Prepaid term {{ invoice.access_start|date:"M j, Y" }}–{{ invoice.access_end|date:"M j, Y" }}{% endif %} · View crypto invoice

+ {% endfor %}

Billing and directory status update automatically, usually within a few minutes. Signing out here does not sign you out of other Dex applications.

{% else %}
@@ -69,8 +72,9 @@

Independent work, supported together

{{ tier.label }}

{{ tier.price_label }}

{{ tier.description|default:"Help keep the projects and community running." }}

-
  • Tier-specific community access
  • Secure checkout hosted by Stripe
  • Manage renewal from your billing portal
- {% if account %}
{% csrf_token %}
{% else %}Create an account to join{% endif %} +
  • Tier-specific community access
  • Recurring payments through Stripe{% if shkeeper_enabled and tier.usd_amount %}, or prepaid crypto through SHKeeper{% endif %}
  • Your free account stays when paid access ends
+ {% if account %}
{% csrf_token %}
+ {% if shkeeper_enabled and tier.usd_amount %}Pay with crypto{% endif %}
{% else %}Create an account to join{% endif %}
{% empty %}

Paid memberships are coming soon.

You can create your free account now. Paid tiers will appear when their prices and groups are configured.

{% endfor %}
@@ -80,6 +84,7 @@
What happens when a membership ends?

Your LDAP account stays. Paid group access is removed automatically when the paid service period expires. An immediate cancellation removes access on the next successful sync.

Where is my password stored?

Only OpenLDAP stores your password hash. This website sends your initial password over verified TLS to OpenLDAP's password-modify operation. Future sign-ins happen through Dex.

Can I change or cancel a plan?

Use Manage billing after signing in. The billing portal offers the changes enabled for your plan. Cancellation at period end keeps access through the paid expiry date.

+ {% if shkeeper_enabled %}
How do crypto memberships renew?

SHKeeper payments prepay one calendar month, without automatic charges. Create a new invoice for each renewal. Confirmed payment on the same tier extends its prepaid term. Partial or unconfirmed payments do not grant access. Do not send renewal funds to an old invoice address.

{% endif %} {% endif %} diff --git a/membership/tiers.eom.json b/membership/tiers.eom.json index bb28ece..a71bf69 100755 --- a/membership/tiers.eom.json +++ b/membership/tiers.eom.json @@ -3,6 +3,7 @@ "label": "Level 0", "price_id": "price_1Rgv4xC5KPXqTvVLfMd6z7wn", "price_label": "$4 USD / month", + "usd_amount": "4.00", "group_dn": "cn=www-level-0,ou=groups,dc=eom,dc=dev", "description": "Support the independent work with a Level 0 membership." }, @@ -10,6 +11,7 @@ "label": "Level 1", "price_id": "price_1Rgv5QC5KPXqTvVLv1PaHWb5", "price_label": "$8 USD / month", + "usd_amount": "8.00", "group_dn": "cn=www-level-1,ou=groups,dc=eom,dc=dev", "description": "Support the independent work with a Level 1 membership." }, @@ -17,6 +19,7 @@ "label": "Level 2", "price_id": "price_1Rgv5rC5KPXqTvVL8Ol8sMUf", "price_label": "$16 USD / month", + "usd_amount": "16.00", "group_dn": "cn=www-level-2,ou=groups,dc=eom,dc=dev", "description": "Support the independent work with a Level 2 membership." }, @@ -24,6 +27,7 @@ "label": "Level 3", "price_id": "price_1Rgv6FC5KPXqTvVL39mAxYjm", "price_label": "$32 USD / month", + "usd_amount": "32.00", "group_dn": "cn=www-level-3,ou=groups,dc=eom,dc=dev", "description": "Support the independent work with a Level 3 membership." }, @@ -31,6 +35,7 @@ "label": "Level 4", "price_id": "price_1Rgv6mC5KPXqTvVLkcmznWZa", "price_label": "$64 USD / month", + "usd_amount": "64.00", "group_dn": "cn=www-level-4,ou=groups,dc=eom,dc=dev", "description": "Support the independent work with a Level 4 membership." }