SHKeeper
This commit is contained in:
Executable
+152
@@ -0,0 +1,152 @@
|
||||
"""SHKeeper API 2.1 invoice integration. No custody, payouts or callback trust."""
|
||||
|
||||
import calendar
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Account, CryptoInvoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def request(method, path, payload=None):
|
||||
if not settings.SHKEEPER_ENABLED:
|
||||
raise ImproperlyConfigured("Configure SHKEEPER_URL, API key and allowed cryptocurrencies.")
|
||||
# Host/path never come from a callback or user URL. No redirect forwarding
|
||||
# of API credentials; TLS verification cannot be disabled, including LANs.
|
||||
with requests.request(method, settings.SHKEEPER_URL + path, json=payload, headers={"X-Shkeeper-Api-Key": settings.SHKEEPER_API_KEY}, timeout=(5, 15), allow_redirects=False, verify=settings.SHKEEPER_CA_CERT_FILE or True, stream=True) as response:
|
||||
if response.status_code != 200:
|
||||
raise ValueError("SHKeeper API did not return success.")
|
||||
content = bytearray()
|
||||
for chunk in response.iter_content(16384):
|
||||
content.extend(chunk)
|
||||
if len(content) > 1048576:
|
||||
raise ValueError("SHKeeper response exceeds the allowed size.")
|
||||
data = json.loads(content)
|
||||
if not isinstance(data, dict) or data.get("status") != "success":
|
||||
raise ValueError("SHKeeper returned an invalid response.")
|
||||
return data
|
||||
|
||||
|
||||
def positive_decimal(value):
|
||||
if not isinstance(value, (str, int)) or isinstance(value, bool) or len(str(value)) > 128:
|
||||
raise ValueError("Invalid SHKeeper amount.")
|
||||
try:
|
||||
result = Decimal(value)
|
||||
except InvalidOperation:
|
||||
raise ValueError("Invalid SHKeeper amount.") from None
|
||||
if not result.is_finite() or result <= 0:
|
||||
raise ValueError("SHKeeper amounts must be finite and positive.")
|
||||
return result
|
||||
|
||||
|
||||
def external_id(invoice):
|
||||
return f"www-membership-{invoice.pk}"
|
||||
|
||||
|
||||
def ensure_created(invoice):
|
||||
"""Stable external ID recovers timeouts without inventing another order.
|
||||
|
||||
SHKeeper's create/update API reuses the external ID. Always reuse the exact
|
||||
original USD amount and asset; never recalculate an order after creation.
|
||||
"""
|
||||
if invoice.provider_id:
|
||||
return
|
||||
data = request("POST", f"/api/v1/{invoice.crypto}/payment_request", {
|
||||
"external_id": external_id(invoice), "fiat": "USD", "amount": str(invoice.usd_amount),
|
||||
"callback_url": settings.PUBLIC_URL + reverse("members:shkeeper_callback"),
|
||||
})
|
||||
provider_id = str(data.get("id", ""))
|
||||
wallet = data.get("wallet")
|
||||
amount = data.get("amount")
|
||||
positive_decimal(amount)
|
||||
if not provider_id or len(provider_id) > 128 or not isinstance(wallet, str) or not wallet or len(wallet) > 512:
|
||||
raise ValueError("Invalid SHKeeper invoice address or identifier.")
|
||||
invoice.provider_id = provider_id
|
||||
invoice.wallet = wallet
|
||||
invoice.crypto_amount = str(amount)
|
||||
invoice.status = "UNPAID"
|
||||
invoice.save(update_fields=["provider_id", "wallet", "crypto_amount", "status"])
|
||||
|
||||
|
||||
def add_month(start):
|
||||
year, month = (start.year + 1, 1) if start.month == 12 else (start.year, start.month + 1)
|
||||
return start.replace(year=year, month=month, day=min(start.day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def refresh_invoice(invoice):
|
||||
ensure_created(invoice)
|
||||
data = request("GET", f"/api/v1/invoices/{external_id(invoice)}")
|
||||
invoices = data.get("invoices")
|
||||
# This app creates exactly one asset invoice for each UUID. Ambiguous
|
||||
# responses/multi-asset payments require review, not guessed attribution.
|
||||
if not isinstance(invoices, list) or len(invoices) != 1 or not isinstance(invoices[0], dict):
|
||||
raise ValueError("Missing or ambiguous SHKeeper invoice.")
|
||||
remote = invoices[0]
|
||||
if str(remote.get("external_id")) != external_id(invoice) or remote.get("fiat") != "USD":
|
||||
raise ValueError("SHKeeper invoice identity/currency mismatch.")
|
||||
amount = positive_decimal(remote.get("amount_fiat"))
|
||||
# SHKeeper may add configured merchant fees, but cannot lower our price.
|
||||
if amount < positive_decimal(str(invoice.usd_amount)):
|
||||
raise ValueError("SHKeeper invoice is below the stored membership price.")
|
||||
status = remote.get("status")
|
||||
if status not in {"UNPAID", "PARTIAL", "PAID", "OVERPAID"}:
|
||||
raise ValueError("Unknown SHKeeper invoice status.")
|
||||
paid = False
|
||||
if status in {"PAID", "OVERPAID"}:
|
||||
balance = positive_decimal(remote.get("balance_fiat"))
|
||||
txs = remote.get("txs")
|
||||
paid = balance >= amount and isinstance(txs, list) and bool(txs) and all(
|
||||
isinstance(tx, dict) and tx.get("status") == "CONFIRMED"
|
||||
and tx.get("crypto") == invoice.crypto and tx.get("addr") == invoice.wallet
|
||||
and isinstance(tx.get("txid"), str) and bool(tx["txid"])
|
||||
for tx in txs
|
||||
)
|
||||
now = timezone.now()
|
||||
if paid and invoice.credited_at is None:
|
||||
# Caller owns the account row lock, so concurrent confirmations cannot
|
||||
# double-credit or allocate overlapping same-tier renewal periods.
|
||||
previous = CryptoInvoice.objects.filter(account=invoice.account, tier=invoice.tier, credited_at__isnull=False).exclude(pk=invoice.pk).order_by("-access_end").first()
|
||||
start = max(now, previous.access_end) if previous and previous.access_end else now
|
||||
invoice.credited_at = now
|
||||
invoice.access_start = start
|
||||
invoice.access_end = add_month(start)
|
||||
invoice.status = status if paid or status in {"UNPAID", "PARTIAL"} else "UNCONFIRMED"
|
||||
invoice.last_checked_at = now
|
||||
invoice.save(update_fields=["status", "credited_at", "access_start", "access_end", "last_checked_at"])
|
||||
|
||||
|
||||
def refresh_account(account):
|
||||
if not settings.SHKEEPER_ENABLED:
|
||||
return True
|
||||
okay = True
|
||||
now = timezone.now()
|
||||
for invoice in CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=now)).order_by("created_at"):
|
||||
try:
|
||||
# Isolate a failed provider read/DB write without undoing other paid
|
||||
# orders. No partial values from an invalid response are persisted.
|
||||
with transaction.atomic():
|
||||
refresh_invoice(invoice)
|
||||
except Exception as exc:
|
||||
okay = False
|
||||
logger.error("SHKeeper refresh failed invoice=%s kind=%s", invoice.pk, type(exc).__name__)
|
||||
if okay:
|
||||
account.last_shkeeper_sync = now
|
||||
account.save(update_fields=["last_shkeeper_sync"])
|
||||
return okay
|
||||
|
||||
|
||||
def prepare_invoice(account_id, invoice_id):
|
||||
with transaction.atomic():
|
||||
account = Account.objects.select_for_update().get(pk=account_id)
|
||||
invoice = CryptoInvoice.objects.get(pk=invoice_id, account=account)
|
||||
ensure_created(invoice)
|
||||
Reference in New Issue
Block a user