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