mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
feat: expose settlement evidence in Greenfield API
This commit is contained in:
@@ -6,6 +6,9 @@ COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
COPY tests /app/tests
|
||||
|
||||
RUN python -m unittest discover -s /app/tests -p "test_*.py"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -316,7 +316,11 @@ def create_invoice(
|
||||
subaddress_index=subaddress_index,
|
||||
amount_xmr=amount_xmr,
|
||||
status="pending",
|
||||
confirmation_target=user.default_confirmation_target,
|
||||
confirmation_target=(
|
||||
checkout.confirmationsRequired
|
||||
if checkout and checkout.confirmationsRequired is not None
|
||||
else user.default_confirmation_target
|
||||
),
|
||||
total_paid_atomic=0,
|
||||
metadata_json=metadata,
|
||||
expires_at=expires_at,
|
||||
@@ -354,6 +358,11 @@ def create_invoice(
|
||||
"status": status_name,
|
||||
"additionalStatus": additional_status,
|
||||
"availableStatusesForManualMarking": ["Invalid"],
|
||||
"confirmationsRequired": invoice.confirmation_target,
|
||||
"confirmationsObserved": invoice.confirmations or 0,
|
||||
"transactionIds": [],
|
||||
"observedAt": invoice.created_at.isoformat() if invoice.created_at else None,
|
||||
"observationSource": "xmrcheckout wallet-rpc",
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +392,12 @@ def get_invoice(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invoice not found",
|
||||
)
|
||||
transfers = (
|
||||
db.query(InvoiceTransfer)
|
||||
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
||||
.order_by(InvoiceTransfer.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
status_name, additional_status = _btcpay_status(invoice)
|
||||
amount, currency, btcpay_data = _btcpay_amount_currency(invoice)
|
||||
expiration_time = _epoch_seconds(invoice.expires_at)
|
||||
@@ -407,6 +422,15 @@ def get_invoice(
|
||||
"status": status_name,
|
||||
"additionalStatus": additional_status,
|
||||
"availableStatusesForManualMarking": ["Invalid"],
|
||||
"confirmationsRequired": invoice.confirmation_target,
|
||||
"confirmationsObserved": invoice.confirmations or 0,
|
||||
"transactionIds": [transfer.txid for transfer in transfers],
|
||||
"observedAt": (
|
||||
transfers[0].updated_at.isoformat()
|
||||
if transfers and transfers[0].updated_at
|
||||
else invoice.created_at.isoformat() if invoice.created_at else None
|
||||
),
|
||||
"observationSource": "xmrcheckout wallet-rpc",
|
||||
}
|
||||
|
||||
|
||||
@@ -548,7 +572,7 @@ def create_webhook(
|
||||
):
|
||||
_require_store(store_id, user)
|
||||
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
||||
secret = generate_webhook_secret()
|
||||
secret = payload.secret or generate_webhook_secret()
|
||||
webhook = BtcpayWebhook(
|
||||
user_id=user.id,
|
||||
url=str(payload.url),
|
||||
@@ -611,6 +635,8 @@ def update_webhook(
|
||||
hook.url = str(payload.url)
|
||||
if payload.authorizedEvents is not None:
|
||||
hook.authorized_events = payload.authorizedEvents.model_dump()
|
||||
if payload.secret is not None:
|
||||
hook.secret_encrypted = encrypt_secret(payload.secret)
|
||||
db.add(hook)
|
||||
db.commit()
|
||||
db.refresh(hook)
|
||||
|
||||
@@ -16,6 +16,7 @@ from pydantic import (
|
||||
class BtcpayCheckout(BaseModel):
|
||||
redirectURL: AnyUrl | None = None
|
||||
speedPolicy: str | None = None
|
||||
confirmationsRequired: conint(ge=0, le=10000) | None = None
|
||||
paymentMethods: list[str] | None = None
|
||||
expirationMinutes: conint(ge=1, le=10080) | None = None
|
||||
monitoringMinutes: conint(ge=0, le=10080) | None = None
|
||||
@@ -52,6 +53,7 @@ class BtcpayWebhookCreate(BaseModel):
|
||||
automaticRedelivery: bool = True
|
||||
url: AnyUrl
|
||||
authorizedEvents: BtcpayWebhookEvents
|
||||
secret: str | None = Field(default=None, min_length=16, max_length=512)
|
||||
|
||||
|
||||
class BtcpayWebhookUpdate(BaseModel):
|
||||
@@ -59,6 +61,7 @@ class BtcpayWebhookUpdate(BaseModel):
|
||||
automaticRedelivery: bool | None = None
|
||||
url: AnyUrl | None = None
|
||||
authorizedEvents: BtcpayWebhookEvents | None = None
|
||||
secret: str | None = Field(default=None, min_length=16, max_length=512)
|
||||
|
||||
|
||||
class BtcpayInvoiceStatusUpdate(BaseModel):
|
||||
|
||||
@@ -12,7 +12,7 @@ import requests
|
||||
from requests import RequestException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import BtcpayWebhook, Invoice
|
||||
from .models import BtcpayWebhook, Invoice, InvoiceTransfer
|
||||
from .security import decrypt_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -39,6 +39,7 @@ def dispatch_btcpay_webhooks(
|
||||
if not hooks:
|
||||
return
|
||||
payload = _build_payload(
|
||||
db=db,
|
||||
event_type=event_type,
|
||||
user_id=user_id,
|
||||
invoice=invoice,
|
||||
@@ -113,6 +114,7 @@ def _event_allowed(authorized_events: object, event_type: str) -> bool:
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
db: Session,
|
||||
event_type: str,
|
||||
user_id: str,
|
||||
invoice: Invoice,
|
||||
@@ -122,6 +124,17 @@ def _build_payload(
|
||||
quote = metadata.get("quote") if isinstance(metadata, dict) else None
|
||||
fiat_amount = quote.get("fiat_amount") if isinstance(quote, dict) else None
|
||||
fiat_currency = quote.get("fiat_currency") if isinstance(quote, dict) else None
|
||||
transfers = (
|
||||
db.query(InvoiceTransfer)
|
||||
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
||||
.order_by(InvoiceTransfer.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
observed_at = (
|
||||
transfers[0].updated_at.isoformat()
|
||||
if transfers and transfers[0].updated_at
|
||||
else invoice.created_at.isoformat() if invoice.created_at else None
|
||||
)
|
||||
return {
|
||||
"type": event_type,
|
||||
"timestamp": int(time.time()),
|
||||
@@ -144,6 +157,11 @@ def _build_payload(
|
||||
if getattr(invoice, "amount_xmr", None) is not None
|
||||
else None
|
||||
),
|
||||
"confirmationsRequired": invoice.confirmation_target,
|
||||
"confirmationsObserved": invoice.confirmations or 0,
|
||||
"transactionIds": [transfer.txid for transfer in transfers],
|
||||
"observedAt": observed_at,
|
||||
"observationSource": "xmrcheckout wallet-rpc",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.btcpay_schemas import BtcpayCheckout, BtcpayWebhookCreate
|
||||
|
||||
|
||||
class BtcpayContractTests(unittest.TestCase):
|
||||
def test_exact_confirmation_target_is_bounded(self):
|
||||
self.assertEqual(BtcpayCheckout(confirmationsRequired=10000).confirmationsRequired, 10000)
|
||||
with self.assertRaises(ValidationError):
|
||||
BtcpayCheckout(confirmationsRequired=10001)
|
||||
|
||||
def test_webhook_accepts_operator_generated_secret(self):
|
||||
webhook = BtcpayWebhookCreate(
|
||||
url="https://evaluetron.example/webhooks/btcpay",
|
||||
secret="operator-generated-secret",
|
||||
authorizedEvents={"specificEvents": ["InvoiceSettled"]},
|
||||
)
|
||||
self.assertEqual(webhook.secret, "operator-generated-secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user