This commit is contained in:
2026-10-23 12:19:40 -04:00
parent 6cf0f0525f
commit faf6c5f2b3
19 changed files with 785 additions and 12 deletions
+9
View File
@@ -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
+1
View File
@@ -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"
],
+106 -1
View File
@@ -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.
+4
View File
@@ -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; }
+19
View File
@@ -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
+12 -4
View File
@@ -1,4 +1,4 @@
"""Canonical Stripe reads, not event arrival order, determine entitlements."""
"""Canonical provider reads, not callback arrival order, determine entitlements."""
import logging
from datetime import datetime, timezone as dt_timezone
@@ -10,7 +10,8 @@ from django.db import transaction
from django.utils import timezone
from .directory import reconcile_groups
from .models import Account, ManagedGroup, Subscription, WebhookEvent
from .models import Account, CryptoInvoice, ManagedGroup, Subscription, WebhookEvent
from . import shkeeper
logger = logging.getLogger(__name__)
@@ -93,6 +94,10 @@ def apply_groups(account):
tier = settings.MEMBERSHIP_TIERS.get(sub.tier)
if tier:
desired.add(tier["group_dn"])
for invoice in CryptoInvoice.objects.filter(account=account, status__in=["PAID", "OVERPAID"], access_start__lte=now, access_end__gt=now):
tier = settings.MEMBERSHIP_TIERS.get(invoice.tier)
if tier:
desired.add(tier["group_dn"])
managed = set(ManagedGroup.objects.filter(account=account).values_list("dn", flat=True))
account.ldap_dn = reconcile_groups(account, desired, managed) if managed else account.ldap_dn
account.last_ldap_sync = now
@@ -101,10 +106,12 @@ def apply_groups(account):
def sync_account(account_id, refresh_stripe=True):
"""Serialize Stripe/LDAP writes per account across web and worker processes.
"""Serialize Stripe/SHKeeper/LDAP writes across web and worker processes.
Local expiry runs even when Stripe is unreachable. LDAP failure is retried on
the next pass, with persisted managed DNs allowing crash-safe revocation.
The existing refresh_stripe=False option means a local-only sweep: neither
provider is contacted, so expiry remains independent of remote availability.
"""
# Commit management intent independently before external side effects. If
# LDAP succeeds but a later DB commit fails, the next sweep still knows
@@ -125,9 +132,10 @@ def sync_account(account_id, refresh_stripe=True):
except Exception as exc:
billing_ok = False
logger.error("Billing refresh failed account=%s kind=%s", account.pk, type(exc).__name__)
crypto_ok = shkeeper.refresh_account(account)
now = timezone.now()
count = apply_groups(account)
if billing_ok:
WebhookEvent.objects.filter(account=account, processed_at__isnull=True).update(processed_at=now)
logger.info("Membership reconciled account=%s groups=%s billing_ok=%s", account.pk, count, billing_ok)
return billing_ok
return billing_ok and crypto_ok
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Reconcile Stripe subscriptions and LDAP groups, including offline expiry."
help = "Reconcile Stripe/SHKeeper payments and LDAP groups, including offline expiry."
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true")
+27
View File
@@ -0,0 +1,27 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("members", "0001_initial")]
operations = [
migrations.AddField(model_name="account", name="last_shkeeper_sync", field=models.DateTimeField(null=True)),
migrations.CreateModel(name="CryptoInvoice", fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("tier", models.CharField(max_length=48)),
("usd_amount", models.DecimalField(max_digits=12, decimal_places=2)),
("crypto", models.CharField(max_length=32)),
("provider_id", models.CharField(blank=True, max_length=128)),
("wallet", models.CharField(blank=True, max_length=512)),
("crypto_amount", models.CharField(blank=True, max_length=128)),
("status", models.CharField(default="creating", max_length=24)),
("credited_at", models.DateTimeField(null=True)),
("access_start", models.DateTimeField(null=True)),
("access_end", models.DateTimeField(db_index=True, null=True)),
("last_checked_at", models.DateTimeField(null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("account", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="members.account")),
], options={"constraints": [models.UniqueConstraint(condition=models.Q(credited_at__isnull=True), fields=("account",), name="one_uncredited_crypto_invoice")]}),
]
+25
View File
@@ -14,6 +14,7 @@ class Account(models.Model):
checkout_id = models.CharField(max_length=128, blank=True)
checkout_key = models.UUIDField(default=uuid.uuid4)
last_billing_sync = models.DateTimeField(null=True)
last_shkeeper_sync = models.DateTimeField(null=True)
last_ldap_sync = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -50,6 +51,30 @@ class ManagedGroup(models.Model):
constraints = [models.UniqueConstraint(fields=["account", "dn"], name="unique_account_group")]
class CryptoInvoice(models.Model):
"""Immutable prepaid order; one canonical invoice can grant one month only."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
account = models.ForeignKey(Account, on_delete=models.PROTECT)
tier = models.CharField(max_length=48)
usd_amount = models.DecimalField(max_digits=12, decimal_places=2)
crypto = models.CharField(max_length=32)
provider_id = models.CharField(max_length=128, blank=True)
wallet = models.CharField(max_length=512, blank=True)
crypto_amount = models.CharField(max_length=128, blank=True)
# SHKeeper invoices do not expire. Keep incomplete orders available for late
# payments; never silently discard a deposit address with money on the way.
status = models.CharField(max_length=24, default="creating")
credited_at = models.DateTimeField(null=True)
access_start = models.DateTimeField(null=True)
access_end = models.DateTimeField(null=True, db_index=True)
last_checked_at = models.DateTimeField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["account"], condition=models.Q(credited_at__isnull=True), name="one_uncredited_crypto_invoice")]
class WebhookEvent(models.Model):
stripe_id = models.CharField(max_length=128, primary_key=True)
account = models.ForeignKey(Account, on_delete=models.CASCADE, null=True)
+152
View File
@@ -0,0 +1,152 @@
"""SHKeeper API 2.1 invoice integration. No custody, payouts or callback trust."""
import calendar
import json
import logging
from decimal import Decimal, InvalidOperation
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from .models import Account, CryptoInvoice
logger = logging.getLogger(__name__)
def request(method, path, payload=None):
if not settings.SHKEEPER_ENABLED:
raise ImproperlyConfigured("Configure SHKEEPER_URL, API key and allowed cryptocurrencies.")
# Host/path never come from a callback or user URL. No redirect forwarding
# of API credentials; TLS verification cannot be disabled, including LANs.
with requests.request(method, settings.SHKEEPER_URL + path, json=payload, headers={"X-Shkeeper-Api-Key": settings.SHKEEPER_API_KEY}, timeout=(5, 15), allow_redirects=False, verify=settings.SHKEEPER_CA_CERT_FILE or True, stream=True) as response:
if response.status_code != 200:
raise ValueError("SHKeeper API did not return success.")
content = bytearray()
for chunk in response.iter_content(16384):
content.extend(chunk)
if len(content) > 1048576:
raise ValueError("SHKeeper response exceeds the allowed size.")
data = json.loads(content)
if not isinstance(data, dict) or data.get("status") != "success":
raise ValueError("SHKeeper returned an invalid response.")
return data
def positive_decimal(value):
if not isinstance(value, (str, int)) or isinstance(value, bool) or len(str(value)) > 128:
raise ValueError("Invalid SHKeeper amount.")
try:
result = Decimal(value)
except InvalidOperation:
raise ValueError("Invalid SHKeeper amount.") from None
if not result.is_finite() or result <= 0:
raise ValueError("SHKeeper amounts must be finite and positive.")
return result
def external_id(invoice):
return f"www-membership-{invoice.pk}"
def ensure_created(invoice):
"""Stable external ID recovers timeouts without inventing another order.
SHKeeper's create/update API reuses the external ID. Always reuse the exact
original USD amount and asset; never recalculate an order after creation.
"""
if invoice.provider_id:
return
data = request("POST", f"/api/v1/{invoice.crypto}/payment_request", {
"external_id": external_id(invoice), "fiat": "USD", "amount": str(invoice.usd_amount),
"callback_url": settings.PUBLIC_URL + reverse("members:shkeeper_callback"),
})
provider_id = str(data.get("id", ""))
wallet = data.get("wallet")
amount = data.get("amount")
positive_decimal(amount)
if not provider_id or len(provider_id) > 128 or not isinstance(wallet, str) or not wallet or len(wallet) > 512:
raise ValueError("Invalid SHKeeper invoice address or identifier.")
invoice.provider_id = provider_id
invoice.wallet = wallet
invoice.crypto_amount = str(amount)
invoice.status = "UNPAID"
invoice.save(update_fields=["provider_id", "wallet", "crypto_amount", "status"])
def add_month(start):
year, month = (start.year + 1, 1) if start.month == 12 else (start.year, start.month + 1)
return start.replace(year=year, month=month, day=min(start.day, calendar.monthrange(year, month)[1]))
def refresh_invoice(invoice):
ensure_created(invoice)
data = request("GET", f"/api/v1/invoices/{external_id(invoice)}")
invoices = data.get("invoices")
# This app creates exactly one asset invoice for each UUID. Ambiguous
# responses/multi-asset payments require review, not guessed attribution.
if not isinstance(invoices, list) or len(invoices) != 1 or not isinstance(invoices[0], dict):
raise ValueError("Missing or ambiguous SHKeeper invoice.")
remote = invoices[0]
if str(remote.get("external_id")) != external_id(invoice) or remote.get("fiat") != "USD":
raise ValueError("SHKeeper invoice identity/currency mismatch.")
amount = positive_decimal(remote.get("amount_fiat"))
# SHKeeper may add configured merchant fees, but cannot lower our price.
if amount < positive_decimal(str(invoice.usd_amount)):
raise ValueError("SHKeeper invoice is below the stored membership price.")
status = remote.get("status")
if status not in {"UNPAID", "PARTIAL", "PAID", "OVERPAID"}:
raise ValueError("Unknown SHKeeper invoice status.")
paid = False
if status in {"PAID", "OVERPAID"}:
balance = positive_decimal(remote.get("balance_fiat"))
txs = remote.get("txs")
paid = balance >= amount and isinstance(txs, list) and bool(txs) and all(
isinstance(tx, dict) and tx.get("status") == "CONFIRMED"
and tx.get("crypto") == invoice.crypto and tx.get("addr") == invoice.wallet
and isinstance(tx.get("txid"), str) and bool(tx["txid"])
for tx in txs
)
now = timezone.now()
if paid and invoice.credited_at is None:
# Caller owns the account row lock, so concurrent confirmations cannot
# double-credit or allocate overlapping same-tier renewal periods.
previous = CryptoInvoice.objects.filter(account=invoice.account, tier=invoice.tier, credited_at__isnull=False).exclude(pk=invoice.pk).order_by("-access_end").first()
start = max(now, previous.access_end) if previous and previous.access_end else now
invoice.credited_at = now
invoice.access_start = start
invoice.access_end = add_month(start)
invoice.status = status if paid or status in {"UNPAID", "PARTIAL"} else "UNCONFIRMED"
invoice.last_checked_at = now
invoice.save(update_fields=["status", "credited_at", "access_start", "access_end", "last_checked_at"])
def refresh_account(account):
if not settings.SHKEEPER_ENABLED:
return True
okay = True
now = timezone.now()
for invoice in CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=now)).order_by("created_at"):
try:
# Isolate a failed provider read/DB write without undoing other paid
# orders. No partial values from an invalid response are persisted.
with transaction.atomic():
refresh_invoice(invoice)
except Exception as exc:
okay = False
logger.error("SHKeeper refresh failed invoice=%s kind=%s", invoice.pk, type(exc).__name__)
if okay:
account.last_shkeeper_sync = now
account.save(update_fields=["last_shkeeper_sync"])
return okay
def prepare_invoice(account_id, invoice_id):
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=account_id)
invoice = CryptoInvoice.objects.get(pk=invoice_id, account=account)
ensure_created(invoice)
+25
View File
@@ -8,6 +8,7 @@ from django.test import TransactionTestCase, override_settings
from members.billing import sync_account
from members.models import Account, ManagedGroup
from members.models import CryptoInvoice
@skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL row locks")
@@ -53,3 +54,27 @@ class PostgreSQLLockTests(TransactionTestCase):
# A later failed external side effect rolls back its own transaction,
# not the previously committed record of the managed group.
self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org")
@override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}})
def test_concurrent_crypto_verification_credits_one_month(self):
invoice = CryptoInvoice.objects.create(account=self.account, tier="test", usd_amount="5.00", crypto="BTC", provider_id="61", wallet="test-wallet", crypto_amount="0.0001", status="UNPAID")
data = {"status": "success", "invoices": [{"external_id": f"www-membership-{invoice.pk}", "fiat": "USD", "amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID", "txs": [{"addr": "test-wallet", "crypto": "BTC", "status": "CONFIRMED", "txid": "tx-confirmed", "amount": "0.0001"}]}]}
def verify():
close_old_connections()
try:
return sync_account(self.account.pk)
finally:
close_old_connections()
from members.shkeeper import add_month
with patch("members.shkeeper.request", return_value=data), patch("members.billing.reconcile_groups", return_value=self.account.ldap_dn):
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: verify(), range(2)))
self.assertTrue(all(results))
invoice.refresh_from_db()
self.assertIsNotNone(invoice.credited_at)
assert invoice.access_start is not None
self.assertEqual(invoice.access_end, add_month(invoice.access_start))
self.assertEqual(CryptoInvoice.objects.count(), 1)
+261
View File
@@ -0,0 +1,261 @@
import json
from datetime import datetime, timedelta, timezone as dt_timezone
from decimal import Decimal
from unittest.mock import patch
from django.conf import settings
from django.db import IntegrityError, transaction
from django.test import Client, SimpleTestCase, TestCase, override_settings
from django.utils import timezone
from members import billing, shkeeper
from members.models import Account, CryptoInvoice, ManagedGroup, Subscription
from .test_membership import DN, GROUP, OTHER_GROUP, TIERS, account, paged
CRYPTO_TIERS = {key: {**value, "usd_amount": "5.00" if key == "supporter" else "15.00"} for key, value in TIERS.items()}
SHKEEPER_SETTINGS = {
"SHKEEPER_ENABLED": True, "SHKEEPER_URL": "https://pay.example.org",
"SHKEEPER_API_KEY": "test-only-key", "SHKEEPER_CA_CERT_FILE": "",
"SHKEEPER_CURRENCIES": ["BTC", "XMR"], "MEMBERSHIP_TIERS": CRYPTO_TIERS,
}
@override_settings(**SHKEEPER_SETTINGS)
class SHKeeperTests(TestCase):
def setUp(self):
self.member = account()
session = self.client.session
session["account_id"] = str(self.member.pk)
session.save()
def invoice(self, **kwargs):
return CryptoInvoice.objects.create(account=self.member, tier="supporter", usd_amount="5.00", crypto="BTC", **kwargs)
def creation(self):
return {"status": "success", "id": 61, "wallet": "bc1-test-wallet", "amount": "0.0001", "recalculate_after": 0}
def remote(self, invoice, **changes):
return {"status": "success", "invoices": [{
"external_id": shkeeper.external_id(invoice), "fiat": "USD",
"amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID",
"txs": [{"status": "CONFIRMED", "addr": "bc1-test-wallet", "crypto": "BTC", "amount": "0.0001", "txid": "confirmed-tx"}], **changes,
}]}
def prepared(self):
return self.invoice(provider_id="61", wallet="bc1-test-wallet", crypto_amount="0.0001", status="UNPAID")
def login_other(self):
other = Account.objects.create(uid="bob", email="bob@example.org", ldap_dn="uid=bob,dc=example", ldap_uuid="uuid-bob")
session = self.client.session
session["account_id"] = str(other.pk)
session.save()
def test_crypto_buttons_only_when_configured(self):
self.assertContains(self.client.get("/members/"), "Pay with crypto", count=2)
with override_settings(SHKEEPER_ENABLED=False):
self.assertNotContains(self.client.get("/members/"), "Pay with crypto")
self.assertEqual(self.client.get("/members/crypto/checkout/supporter/").status_code, 404)
def test_checkout_form_csrf_login_and_currency_allowlist(self):
self.assertContains(self.client.get("/members/crypto/checkout/supporter/"), "not an automatically renewing")
self.assertEqual(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "../../evil"}).status_code, 400)
self.assertFalse(CryptoInvoice.objects.exists())
strict = Client(enforce_csrf_checks=True)
self.assertEqual(strict.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}).status_code, 403)
self.client.post("/members/logout/")
self.assertRedirects(self.client.get("/members/crypto/checkout/supporter/"), "/members/")
@patch("members.shkeeper.request")
def test_checkout_uses_server_price_and_stable_uuid(self, request):
request.return_value = self.creation()
response = self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC", "usd_amount": "0.01", "account": "victim"})
invoice = CryptoInvoice.objects.get()
self.assertRedirects(response, f"/members/crypto/invoice/{invoice.pk}/")
self.assertEqual(invoice.usd_amount, Decimal("5.00"))
self.assertEqual(invoice.account, self.member)
method, path, params = request.call_args.args
self.assertEqual((method, path), ("POST", "/api/v1/BTC/payment_request"))
self.assertEqual(params["amount"], "5.00")
self.assertEqual(params["external_id"], shkeeper.external_id(invoice))
self.assertEqual(params["callback_url"], settings.PUBLIC_URL + "/members/shkeeper/callback/")
self.assertIsNone(invoice.credited_at)
self.assertEqual(ManagedGroup.objects.count(), 2)
# Another crypto/tier request reopens the previous immutable order.
self.assertRedirects(self.client.post("/members/crypto/checkout/patron/", {"crypto": "XMR"}), f"/members/crypto/invoice/{invoice.pk}/")
self.assertEqual(request.call_count, 1)
self.assertEqual(CryptoInvoice.objects.count(), 1)
@patch("members.shkeeper.request", side_effect=OSError("timeout"))
def test_creation_timeout_keeps_reserved_order_for_worker(self, request):
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "checkout unavailable")
invoice = CryptoInvoice.objects.get()
self.assertEqual(invoice.status, "creating")
first_id = request.call_args.args[2]["external_id"]
request.side_effect = None
request.return_value = self.creation()
shkeeper.prepare_invoice(self.member.pk, invoice.pk)
self.assertEqual(request.call_args.args[2]["external_id"], first_id)
def test_only_one_uncredited_order_constraint(self):
self.invoice()
with self.assertRaises(IntegrityError), transaction.atomic():
self.invoice()
def test_invoice_is_account_scoped_and_get_never_calls_provider(self):
invoice = self.prepared()
with patch("members.shkeeper.request") as request:
self.assertContains(self.client.get(f"/members/crypto/invoice/{invoice.pk}/"), "bc1-test-wallet")
request.assert_not_called()
self.login_other()
self.assertEqual(self.client.get(f"/members/crypto/invoice/{invoice.pk}/").status_code, 404)
@patch("members.shkeeper.request")
def test_spoofed_callback_cannot_grant_or_trigger_network(self, request):
invoice = self.prepared()
for _ in range(3):
self.assertEqual(self.client.post("/members/shkeeper/callback/", json.dumps({"external_id": shkeeper.external_id(invoice), "paid": True, "status": "PAID", "balance_fiat": "999999"}), content_type="application/json").status_code, 200)
request.assert_not_called()
invoice.refresh_from_db()
self.assertIsNone(invoice.credited_at)
self.assertEqual(invoice.status, "UNPAID")
self.assertEqual(self.client.get("/members/shkeeper/callback/").status_code, 405)
@patch("members.shkeeper.request")
def test_canonical_paid_once_repeated_callbacks_do_not_extend(self, request):
invoice = self.prepared()
request.return_value = self.remote(invoice)
with transaction.atomic():
shkeeper.refresh_invoice(invoice)
first_end = invoice.access_end
self.assertEqual(invoice.access_end, shkeeper.add_month(invoice.access_start))
shkeeper.refresh_invoice(invoice)
self.assertEqual(invoice.access_end, first_end)
self.assertEqual(invoice.status, "PAID")
@patch("members.shkeeper.request")
def test_partial_unpaid_unconfirmed_underpaid_cannot_grant(self, request):
invoice = self.prepared()
for changes in ({"status": "UNPAID", "balance_fiat": "0"}, {"status": "PARTIAL", "balance_fiat": "1"}, {"balance_fiat": "4.99"}, {"txs": []}, {"txs": [{"status": "UNCONFIRMED"}]}, {"txs": [{"status": "CONFIRMED", "addr": "wrong", "crypto": "BTC", "txid": "x"}]}):
request.return_value = self.remote(invoice, **changes)
shkeeper.refresh_invoice(invoice)
self.assertIsNone(invoice.credited_at)
@patch("members.shkeeper.request")
def test_mismatched_invalid_amount_or_ambiguous_data_rejected(self, request):
invoice = self.prepared()
for changes in ({"external_id": "another-order"}, {"fiat": "EUR"}, {"amount_fiat": "0.01"}, {"amount_fiat": "NaN"}, {"balance_fiat": "Infinity"}, {"status": "unknown"}):
request.return_value = self.remote(invoice, **changes)
with self.assertRaises(ValueError):
shkeeper.refresh_invoice(invoice)
data = self.remote(invoice)
data["invoices"] *= 2
request.return_value = data
with self.assertRaises(ValueError):
shkeeper.refresh_invoice(invoice)
invoice.refresh_from_db()
self.assertIsNone(invoice.credited_at)
@patch("members.shkeeper.request")
def test_renewal_stacks_calendar_months_and_overpayment_once(self, request):
first = self.prepared()
request.return_value = self.remote(first)
shkeeper.refresh_invoice(first)
second = self.prepared()
request.return_value = self.remote(second, status="OVERPAID", balance_fiat="50")
shkeeper.refresh_invoice(second)
self.assertEqual(second.access_start, first.access_end)
self.assertEqual(second.access_end, shkeeper.add_month(first.access_end))
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.shkeeper.request")
def test_paid_crypto_grant_expiry_and_outage(self, request, ldap):
invoice = self.prepared()
request.return_value = self.remote(invoice)
self.assertTrue(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP})
request.side_effect = OSError("SHKeeper offline")
self.assertFalse(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP})
CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1))
billing.sync_account(self.member.pk, refresh_stripe=False)
self.assertEqual(ldap.call_args.args[1], set())
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.shkeeper.request")
def test_reorg_revokes_and_recovery_does_not_restart_month(self, request, ldap):
invoice = self.prepared()
request.return_value = self.remote(invoice)
billing.sync_account(self.member.pk)
invoice.refresh_from_db()
original_end = invoice.access_end
request.return_value = self.remote(invoice, status="UNPAID", balance_fiat="0", txs=[])
billing.sync_account(self.member.pk)
self.assertEqual(ldap.call_args.args[1], set())
request.return_value = self.remote(invoice)
billing.sync_account(self.member.pk)
invoice.refresh_from_db()
self.assertEqual(invoice.access_end, original_end)
self.assertEqual(ldap.call_args.args[1], {GROUP})
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.billing.refresh_billing", side_effect=OSError("Stripe offline"))
@patch("members.shkeeper.request")
def test_stripe_failure_does_not_prevent_crypto_and_union(self, request, stripe, ldap):
invoice = self.prepared()
request.return_value = self.remote(invoice)
Subscription.objects.create(account=self.member, stripe_id="sub_other", tier="patron", status="active", paid_until=timezone.now() + timedelta(days=1))
self.assertFalse(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP})
with override_settings(MEMBERSHIP_TIERS={}):
billing.sync_account(self.member.pk, refresh_stripe=False)
self.assertEqual(ldap.call_args.args[1], set())
self.assertIn(GROUP, ldap.call_args.args[2])
@patch("members.shkeeper.request")
@patch("members.views.billing.client")
def test_active_stripe_and_open_checkout_block_crypto(self, stripe, request):
self.member.stripe_customer = "cus_alice"
self.member.save()
stripe.return_value.subscriptions.list.return_value = paged([{"status": "active"}])
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "Recurring membership already exists")
stripe.return_value.subscriptions.list.return_value = paged([])
Account.objects.filter(pk=self.member.pk).update(checkout_id="cs_open")
stripe.return_value.checkout.sessions.retrieve.return_value = {"status": "open"}
self.assertContains(self.client.post("/members/crypto/checkout/supporter/", {"crypto": "BTC"}), "Stripe checkout is still open")
request.assert_not_called()
self.assertFalse(CryptoInvoice.objects.exists())
@patch("members.views.billing.client")
def test_unfinished_or_active_crypto_blocks_stripe(self, stripe):
invoice = self.prepared()
self.assertContains(self.client.post("/members/checkout/supporter/"), "Prepaid membership already exists")
stripe.assert_not_called()
CryptoInvoice.objects.filter(pk=invoice.pk).update(credited_at=timezone.now(), access_end=timezone.now() + timedelta(days=1))
self.assertContains(self.client.post("/members/crypto/checkout/patron/", {"crypto": "BTC"}), "Another prepaid tier is active")
@override_settings(**SHKEEPER_SETTINGS)
class SHKeeperTransportTests(SimpleTestCase):
@patch("members.shkeeper.requests.request")
def test_api_auth_tls_timeout_no_redirects_and_limits(self, request):
response = request.return_value.__enter__.return_value
response.status_code = 200
response.iter_content.return_value = [b'{"status":"success","invoices":[]}']
self.assertEqual(shkeeper.request("GET", "/api/v1/invoices/order")["invoices"], [])
self.assertEqual(request.call_args.args[:2], ("GET", "https://pay.example.org/api/v1/invoices/order"))
self.assertEqual(request.call_args.kwargs["headers"]["X-Shkeeper-Api-Key"], "test-only-key")
self.assertIs(request.call_args.kwargs["verify"], True)
self.assertFalse(request.call_args.kwargs["allow_redirects"])
self.assertEqual(request.call_args.kwargs["timeout"], (5, 15))
response.status_code = 302
with self.assertRaises(ValueError):
shkeeper.request("GET", "/api/v1/invoices/order")
response.status_code = 200
response.iter_content.return_value = [b"x" * 1048577]
with self.assertRaises(ValueError):
shkeeper.request("GET", "/api/v1/invoices/order")
def test_calendar_month_handles_leap_year_and_december(self):
january = datetime(2028, 1, 31, tzinfo=dt_timezone.utc)
self.assertEqual(shkeeper.add_month(january), datetime(2028, 2, 29, tzinfo=dt_timezone.utc))
self.assertEqual(shkeeper.add_month(datetime(2027, 12, 15, tzinfo=dt_timezone.utc)), datetime(2028, 1, 15, tzinfo=dt_timezone.utc))
+3
View File
@@ -14,4 +14,7 @@ urlpatterns = [
path("checkout/<slug:tier>/", views.checkout, name="checkout"),
path("billing/", views.portal, name="portal"),
path("stripe/webhook/", views.webhook, name="webhook"),
path("crypto/checkout/<slug:tier>/", views.crypto_checkout, name="crypto_checkout"),
path("crypto/invoice/<uuid:invoice_id>/", views.crypto_invoice, name="crypto_invoice"),
path("shkeeper/callback/", views.shkeeper_callback, name="shkeeper_callback"),
]
+67 -4
View File
@@ -12,7 +12,8 @@ from django.conf import settings
from django.core.mail import send_mail
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.shortcuts import get_object_or_404, redirect, render
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import salted_hmac
@@ -20,9 +21,9 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from . import billing, directory
from . import billing, directory, shkeeper
from .forms import PasswordForm, RegistrationForm
from .models import Account, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
from .models import Account, CryptoInvoice, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
logger = logging.getLogger(__name__)
@@ -69,7 +70,8 @@ def remember_groups(account):
def home(request):
account = Account.objects.filter(pk=request.session.get("account_id")).first() if request.session.get("account_id") else None
subscriptions = Subscription.objects.filter(account=account) if account else []
return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, now=timezone.now())
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
@require_http_methods(["GET", "POST"])
@@ -224,6 +226,8 @@ def checkout(request, tier):
try:
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=request.member.pk)
if CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=timezone.now())).exists():
return page(request, title="Prepaid membership already exists", message="Finish your SHKeeper invoice or wait for prepaid membership to expire before starting a recurring Stripe subscription.")
api = billing.client()
if not account.stripe_customer:
customer = api.customers.create({"email": account.email, "metadata": {"membership_account": str(account.pk)}}, options={"idempotency_key": f"member-customer-{account.pk}"})
@@ -287,3 +291,62 @@ def webhook(request):
except (ValueError, KeyError, TypeError, stripe.SignatureVerificationError):
return HttpResponse(status=400)
return HttpResponse(status=200)
@require_http_methods(["GET", "POST"])
@account_required
def crypto_checkout(request, tier):
config = settings.MEMBERSHIP_TIERS.get(tier)
if not settings.SHKEEPER_ENABLED or not config or not config.get("usd_amount"):
return HttpResponse(status=404)
if request.method == "GET":
return render(request, "crypto_checkout.html", {"tier": config, "tier_key": tier, "currencies": settings.SHKEEPER_CURRENCIES})
crypto = request.POST.get("crypto", "")
if crypto not in settings.SHKEEPER_CURRENCIES:
return HttpResponse("Choose an available cryptocurrency.", status=400)
if limited("crypto-checkout", str(request.member.pk), 20):
return HttpResponse(status=429)
try:
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=request.member.pk)
invoice = CryptoInvoice.objects.filter(account=account, credited_at__isnull=True).first()
if invoice:
# Reuse the existing asset/tier even if a stale tab asks for a
# different one. Never orphan an address a user may have paid.
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
if account.stripe_customer:
api = billing.client()
subs = api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100})
if any(sub.get("status") not in {"canceled", "incomplete_expired"} for sub in subs.auto_paging_iter()):
return page(request, title="Recurring membership already exists", message="Use Stripe billing to cancel renewal and wait until that subscription ends before paying through SHKeeper. This avoids double payments.")
if account.checkout_id:
checkout_session = api.checkout.sessions.retrieve(account.checkout_id)
if checkout_session.get("status") == "open":
return page(request, title="Stripe checkout is still open", message="Finish or let the existing Stripe Checkout expire before changing payment providers.")
if CryptoInvoice.objects.filter(account=account, access_end__gt=timezone.now()).exclude(tier=tier).exists():
return page(request, title="Another prepaid tier is active", message="Renew the same tier, or wait for the current prepaid membership to expire before changing tiers.")
invoice = CryptoInvoice.objects.create(account=account, tier=tier, crypto=crypto, usd_amount=config["usd_amount"])
remember_groups(account)
# Reserve/commit UUID BEFORE API call, so retries and worker recovery
# use the same external ID if the process dies after invoice creation.
shkeeper.prepare_invoice(account.pk, invoice.pk)
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
except Exception as exc:
logger.error("SHKeeper checkout failed account=%s kind=%s", request.member.pk, type(exc).__name__)
return page(request, title="Crypto checkout unavailable", message="Please try again later. If an invoice was reserved, it remains on your membership page and will be retried automatically. Do not send funds until payment details appear.")
@require_GET
@account_required
def crypto_invoice(request, invoice_id):
invoice = get_object_or_404(CryptoInvoice, pk=invoice_id, account=request.member)
return render(request, "crypto_invoice.html", {"invoice": invoice, "now": timezone.now()})
@csrf_exempt
@require_POST
def shkeeper_callback(request):
# SHKeeper documents no callback signature. Treat ALL incoming payloads as
# untrusted hints: do not parse them, write billing data, trigger network
# requests or grant access. The durable worker polls known invoice UUIDs.
return HttpResponse(status=200 if settings.SHKEEPER_ENABLED else 503)
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Crypto membership · eom.dev</title>
<link rel="stylesheet" href="/static/home.css">
<link rel="stylesheet" href="/static/membership.css">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
</head>
<body>
<header class="member-nav"><a class="brand" href="/">eom.dev</a><nav aria-label="Membership navigation"><a href="{% url 'members:home' %}">Membership</a></nav></header>
<main id="main-content" class="member-main">
<section class="member-panel form-panel">
<p class="eyebrow">Crypto payments · Powered by SHKeeper</p>
{% block content %}{% endblock %}
<p><a href="{% url 'members:home' %}">Back to membership</a></p>
</section>
</main>
<footer class="member-footer">Payment questions? <a href="mailto:eric@eom.dev">Contact the administrator</a>.</footer>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
{% extends "crypto_base.html" %}
{% block content %}
<h1>Prepay {{ tier.label }}</h1>
<p class="tier-price">${{ tier.usd_amount }} USD <span>/ one calendar month</span></p>
<p>Pay once with cryptocurrency. This is <strong>not an automatically renewing subscription</strong>. Renewing the same tier adds one calendar month after your current prepaid term ends.</p>
<form method="post">
{% csrf_token %}
<p><label for="crypto">Cryptocurrency and network</label>
<select id="crypto" name="crypto" required>{% for currency in currencies %}<option value="{{ currency }}">{{ currency }}</option>{% endfor %}</select></p>
<p class="member-small">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.</p>
<button class="button button-primary" type="submit">Create payment invoice</button>
</form>
<p class="member-small">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.</p>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "crypto_base.html" %}
{% block content %}
<h1>{{ invoice.tier|title }} invoice</h1>
<p class="member-notice" role="status">SHKeeper status: <strong>{{ invoice.status }}</strong></p>
<dl class="crypto-details">
<dt>Invoice reference</dt><dd>{{ invoice.id }}</dd>
<dt>Membership amount</dt><dd>${{ invoice.usd_amount }} USD · one calendar month</dd>
<dt>Cryptocurrency / network</dt><dd><strong>{{ invoice.crypto }}</strong></dd>
{% if invoice.wallet %}<dt>Deposit address</dt><dd><code>{{ invoice.wallet }}</code></dd>
<dt>Initial crypto quote</dt><dd>{{ invoice.crypto_amount }} {{ invoice.crypto }}</dd>{% endif %}
{% if invoice.last_checked_at %}<dt>Last checked</dt><dd>{{ invoice.last_checked_at|date:"M j, Y H:i:s T" }}</dd>{% endif %}
</dl>
{% if invoice.credited_at %}
<p>One prepaid term has been recorded: <strong>{{ invoice.access_start|date:"M j, Y H:i T" }}</strong> through <strong>{{ invoice.access_end|date:"M j, Y H:i T" }}</strong>.</p>
<p>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.</p>
{% elif invoice.wallet %}
<p>Send only <strong>{{ invoice.crypto }}</strong> on its correct network to this address. Wait for blockchain confirmations and automatic verification before expecting access.</p>
<p class="member-small">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.</p>
{% else %}
<p>The invoice is reserved and awaiting payment details from SHKeeper. The worker retries automatically. Do not send funds until an address appears.</p>
{% endif %}
<p><a class="button button-secondary" href="{% url 'members:crypto_invoice' invoice.id %}">Refresh payment status</a></p>
<p class="member-small">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.</p>
{% endblock %}
+7 -2
View File
@@ -48,6 +48,9 @@
{% for subscription in subscriptions %}
<p class="member-notice">{{ 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 %}</p>
{% endfor %}
{% for invoice in crypto_invoices %}
<p class="member-notice">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 %} · <a href="{% url 'members:crypto_invoice' invoice.id %}">View crypto invoice</a></p>
{% endfor %}
<p class="member-small">Billing and directory status update automatically, usually within a few minutes. Signing out here does not sign you out of other Dex applications.</p>
{% else %}
<div class="member-actions">
@@ -69,8 +72,9 @@
<article class="member-panel tier-card">
<p class="card-kicker">Independent work, supported together</p><h3>{{ tier.label }}</h3><p class="tier-price">{{ tier.price_label }}</p>
<p>{{ tier.description|default:"Help keep the projects and community running." }}</p>
<ul><li>Tier-specific community access</li><li>Secure checkout hosted by Stripe</li><li>Manage renewal from your billing portal</li></ul>
{% if account %}<form action="{% url 'members:checkout' key %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Choose {{ tier.label }}</button></form>{% else %}<a class="button button-primary" href="{% url 'members:register' %}">Create an account to join</a>{% endif %}
<ul><li>Tier-specific community access</li><li>Recurring payments through Stripe{% if shkeeper_enabled and tier.usd_amount %}, or prepaid crypto through SHKeeper{% endif %}</li><li>Your free account stays when paid access ends</li></ul>
{% if account %}<div class="tier-payment-actions"><form action="{% url 'members:checkout' key %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Subscribe with Stripe</button></form>
{% if shkeeper_enabled and tier.usd_amount %}<a class="button button-secondary" href="{% url 'members:crypto_checkout' key %}">Pay with crypto</a>{% endif %}</div>{% else %}<a class="button button-primary" href="{% url 'members:register' %}">Create an account to join</a>{% endif %}
</article>
{% empty %}<div class="member-panel"><h3>Paid memberships are coming soon.</h3><p>You can create your free account now. Paid tiers will appear when their prices and groups are configured.</p></div>{% endfor %}
</div>
@@ -80,6 +84,7 @@
<details><summary>What happens when a membership ends?</summary><p>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.</p></details>
<details><summary>Where is my password stored?</summary><p>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.</p></details>
<details><summary>Can I change or cancel a plan?</summary><p>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.</p></details>
{% if shkeeper_enabled %}<details><summary>How do crypto memberships renew?</summary><p>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.</p></details>{% endif %}
</section>
{% endif %}
</main>
+5
View File
@@ -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."
}