153 lines
7.0 KiB
Python
Executable File
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)
|