Files
2026-10-23 13:25:45 -04:00

153 lines
7.0 KiB
Python
Executable File

"""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)