mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
266 lines
10 KiB
Python
266 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal, ROUND_DOWN
|
|
|
|
from sqlalchemy import and_, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
|
from .config import INVOICE_RECONCILE_INTERVAL_SECONDS, LATE_PAYMENT_LOOKBACK_HOURS
|
|
from .db import SessionLocal
|
|
from .models import Invoice, InvoiceTransfer, User
|
|
from .monero_service import MoneroWalletService, TransferDetail
|
|
from .webhooks import dispatch_webhooks
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def main() -> None:
|
|
level_name = "INFO"
|
|
try:
|
|
level_name = __import__("os").getenv("LOG_LEVEL", "INFO")
|
|
except Exception:
|
|
level_name = "INFO"
|
|
level = getattr(logging, level_name.upper(), logging.INFO)
|
|
logging.basicConfig(level=level)
|
|
service = MoneroWalletService()
|
|
while True:
|
|
try:
|
|
_reconcile_invoices(service)
|
|
except Exception as exc:
|
|
logger.exception("Invoice reconcile failed: %s", exc)
|
|
time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS)
|
|
|
|
|
|
def _reconcile_invoices(service: MoneroWalletService) -> None:
|
|
db: Session = SessionLocal()
|
|
try:
|
|
now = datetime.now(timezone.utc)
|
|
late_cutoff = now - timedelta(hours=max(0, LATE_PAYMENT_LOOKBACK_HOURS))
|
|
invoices = (
|
|
db.query(Invoice)
|
|
.filter(
|
|
or_(
|
|
Invoice.status.in_(["pending", "payment_detected"]),
|
|
and_(
|
|
Invoice.status == "expired",
|
|
Invoice.expires_at.is_not(None),
|
|
Invoice.expires_at >= late_cutoff,
|
|
),
|
|
)
|
|
)
|
|
.order_by(Invoice.created_at.asc())
|
|
.all()
|
|
)
|
|
user_groups: dict[object, list[Invoice]] = {}
|
|
for invoice in invoices:
|
|
if invoice.user_id is None:
|
|
logger.debug(
|
|
"Skipping invoice without user",
|
|
extra={"invoice_id": str(invoice.id)},
|
|
)
|
|
continue
|
|
user_groups.setdefault(invoice.user_id, []).append(invoice)
|
|
logger.debug(
|
|
"Reconciling %d invoices across %d users",
|
|
len(invoices),
|
|
len(user_groups),
|
|
)
|
|
for user_id, user_invoices in user_groups.items():
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if user is None:
|
|
logger.debug(
|
|
"Skipping invoices with missing user",
|
|
extra={"user_id": str(user_id)},
|
|
)
|
|
continue
|
|
if not user.payment_address or not user.view_key_encrypted:
|
|
logger.debug(
|
|
"Skipping invoices without payment address",
|
|
extra={"user_id": str(user.id)},
|
|
)
|
|
continue
|
|
for invoice in user_invoices:
|
|
try:
|
|
transfers = service.get_transfers_for_address(
|
|
user=user,
|
|
address=invoice.address,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Skipping invoice reconcile due to wallet RPC error",
|
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
|
)
|
|
logger.debug("Wallet RPC error: %s", exc)
|
|
continue
|
|
total_atomic = 0
|
|
max_confirmations = 0
|
|
for transfer in transfers:
|
|
if transfer.amount_atomic <= 0:
|
|
continue
|
|
total_atomic += transfer.amount_atomic
|
|
if transfer.confirmations > max_confirmations:
|
|
max_confirmations = transfer.confirmations
|
|
logger.debug(
|
|
"Invoice totals",
|
|
extra={
|
|
"invoice_id": str(invoice.id),
|
|
"received_atomic": total_atomic,
|
|
"confirmations": max_confirmations,
|
|
},
|
|
)
|
|
now = datetime.now(timezone.utc)
|
|
previous_confirmations = invoice.confirmations or 0
|
|
total_changed = invoice.total_paid_atomic != total_atomic
|
|
confirmations_changed = previous_confirmations != max_confirmations
|
|
transfers_changed = _sync_invoice_transfers(
|
|
db,
|
|
invoice=invoice,
|
|
transfers=transfers,
|
|
)
|
|
if total_changed or confirmations_changed or transfers_changed:
|
|
if confirmations_changed:
|
|
invoice.confirmations = max_confirmations
|
|
if total_changed:
|
|
invoice.total_paid_atomic = total_atomic
|
|
db.add(invoice)
|
|
db.commit()
|
|
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
|
is_paid = total_atomic >= required_atomic
|
|
|
|
expires_at = invoice.expires_at
|
|
if expires_at is not None and expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
is_after_expiry = bool(expires_at and now >= expires_at)
|
|
|
|
if total_atomic < required_atomic:
|
|
logger.debug(
|
|
"Payment not yet detected",
|
|
extra={
|
|
"invoice_id": str(invoice.id),
|
|
"required_atomic": required_atomic,
|
|
"received_atomic": total_atomic,
|
|
},
|
|
)
|
|
if invoice.status == "pending" and is_after_expiry:
|
|
logger.info(
|
|
"Invoice expired",
|
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
|
)
|
|
invoice.status = "expired"
|
|
db.add(invoice)
|
|
db.commit()
|
|
dispatch_webhooks(db, str(user.id), "invoice.expired", invoice)
|
|
dispatch_btcpay_webhooks(
|
|
db, str(user.id), "InvoiceExpired", invoice
|
|
)
|
|
continue
|
|
|
|
if is_paid and invoice.status in ("pending", "expired"):
|
|
logger.info(
|
|
"Invoice marked payment detected",
|
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
|
)
|
|
previous_status = invoice.status
|
|
invoice.status = "payment_detected"
|
|
if invoice.detected_at is None:
|
|
invoice.detected_at = now
|
|
if previous_status == "expired" or (previous_status == "pending" and is_after_expiry):
|
|
invoice.paid_after_expiry = True
|
|
if invoice.paid_after_expiry_at is None:
|
|
invoice.paid_after_expiry_at = now
|
|
db.add(invoice)
|
|
db.commit()
|
|
dispatch_webhooks(db, str(user.id), "invoice.payment_detected", invoice)
|
|
dispatch_btcpay_webhooks(
|
|
db, str(user.id), "InvoiceReceivedPayment", invoice
|
|
)
|
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoicePaidInFull", invoice)
|
|
dispatch_btcpay_webhooks(
|
|
db, str(user.id), "InvoiceProcessing", invoice
|
|
)
|
|
if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed":
|
|
logger.info(
|
|
"Invoice confirmed",
|
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
|
)
|
|
invoice.status = "confirmed"
|
|
if invoice.confirmed_at is None:
|
|
invoice.confirmed_at = now
|
|
db.add(invoice)
|
|
db.commit()
|
|
dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice)
|
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice)
|
|
dispatch_btcpay_webhooks(
|
|
db, str(user.id), "InvoicePaymentSettled", invoice
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _sync_invoice_transfers(
|
|
db: Session,
|
|
*,
|
|
invoice: Invoice,
|
|
transfers: list[TransferDetail],
|
|
) -> bool:
|
|
existing = (
|
|
db.query(InvoiceTransfer)
|
|
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
|
.all()
|
|
)
|
|
existing_by_txid = {transfer.txid: transfer for transfer in existing if transfer.txid}
|
|
seen_txids: set[str] = set()
|
|
changed = False
|
|
for transfer in transfers:
|
|
txid = transfer.txid
|
|
if not txid:
|
|
continue
|
|
seen_txids.add(txid)
|
|
stored = existing_by_txid.get(txid)
|
|
if stored is None:
|
|
db.add(
|
|
InvoiceTransfer(
|
|
invoice_id=invoice.id,
|
|
txid=txid,
|
|
amount_atomic=transfer.amount_atomic,
|
|
confirmations=transfer.confirmations,
|
|
timestamp=transfer.timestamp,
|
|
address=transfer.address,
|
|
)
|
|
)
|
|
changed = True
|
|
continue
|
|
if (
|
|
stored.amount_atomic != transfer.amount_atomic
|
|
or stored.confirmations != transfer.confirmations
|
|
or stored.timestamp != transfer.timestamp
|
|
or stored.address != transfer.address
|
|
):
|
|
stored.amount_atomic = transfer.amount_atomic
|
|
stored.confirmations = transfer.confirmations
|
|
stored.timestamp = transfer.timestamp
|
|
stored.address = transfer.address
|
|
changed = True
|
|
for stored in existing:
|
|
if stored.txid not in seen_txids:
|
|
db.delete(stored)
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
|
|
|
|
def _xmr_to_atomic(amount: Decimal) -> int:
|
|
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
|
|
rounding=ROUND_DOWN
|
|
)
|
|
return int(quantized)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|