Payments working
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
|
||||||
@@ -220,13 +221,19 @@ def check_revolut_signature(
|
|||||||
raise ValueError("Revolut webhook cannot be verified.")
|
raise ValueError("Revolut webhook cannot be verified.")
|
||||||
|
|
||||||
timestamp = int(timestamp_header)
|
timestamp = int(timestamp_header)
|
||||||
if abs(time.time() - timestamp) > tolerance_seconds:
|
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||||
|
if not math.isfinite(timestamp_seconds):
|
||||||
|
logger.warning("Invalid Revolut timestamp.")
|
||||||
|
raise ValueError("Invalid Revolut timestamp.")
|
||||||
|
|
||||||
|
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
|
||||||
logger.warning("Timestamp outside tolerance.")
|
logger.warning("Timestamp outside tolerance.")
|
||||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||||
|
|
||||||
candidates = [
|
candidates = [
|
||||||
|
b"v1." + timestamp_header.encode() + b"." + payload,
|
||||||
payload,
|
payload,
|
||||||
f"{timestamp}.{payload.decode()}".encode(),
|
f"{timestamp_header}.{payload.decode()}".encode(),
|
||||||
timestamp_header.encode() + b"." + payload,
|
timestamp_header.encode() + b"." + payload,
|
||||||
]
|
]
|
||||||
signatures = []
|
signatures = []
|
||||||
@@ -234,9 +241,16 @@ def check_revolut_signature(
|
|||||||
digest = hmac.new(
|
digest = hmac.new(
|
||||||
key=secret.encode(), msg=candidate, digestmod=hashlib.sha256
|
key=secret.encode(), msg=candidate, digestmod=hashlib.sha256
|
||||||
).digest()
|
).digest()
|
||||||
signatures.extend([digest.hex(), b64encode(digest).decode()])
|
signatures.extend(
|
||||||
|
[digest.hex(), f"v1={digest.hex()}", b64encode(digest).decode()]
|
||||||
|
)
|
||||||
|
|
||||||
if not any(hmac.compare_digest(expected, sig_header) for expected in signatures):
|
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
|
||||||
|
if not any(
|
||||||
|
hmac.compare_digest(expected, provided)
|
||||||
|
for expected in signatures
|
||||||
|
for provided in provided_signatures
|
||||||
|
):
|
||||||
logger.warning("Revolut signature verification failed.")
|
logger.warning("Revolut signature verification failed.")
|
||||||
raise ValueError("Revolut signature verification failed.")
|
raise ValueError("Revolut signature verification failed.")
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,35 @@ from http import HTTPStatus
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from lnbits.core.crud.settings import set_settings_field
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
from lnbits.core.models.wallets import WalletTypeInfo
|
from lnbits.core.models.wallets import WalletTypeInfo
|
||||||
|
from lnbits.core.services import update_cached_settings
|
||||||
from lnbits.core.services.fiat_providers import test_connection
|
from lnbits.core.services.fiat_providers import test_connection
|
||||||
from lnbits.decorators import check_admin, require_admin_key
|
from lnbits.decorators import check_admin, require_admin_key
|
||||||
from lnbits.fiat import StripeWallet, get_fiat_provider
|
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
|
||||||
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
|
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
|
||||||
|
|
||||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||||
|
|
||||||
|
|
||||||
|
class RevolutCreateWebhook(BaseModel):
|
||||||
|
url: str
|
||||||
|
endpoint: str | None = None
|
||||||
|
api_secret_key: str | None = None
|
||||||
|
api_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RevolutCreateWebhookResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
url: str
|
||||||
|
events: list[str] = []
|
||||||
|
signing_secret: str
|
||||||
|
already_exists: bool = False
|
||||||
|
|
||||||
|
|
||||||
@fiat_router.put(
|
@fiat_router.put(
|
||||||
"/check/{provider}",
|
"/check/{provider}",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
@@ -22,6 +40,54 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
|||||||
return await test_connection(provider)
|
return await test_connection(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@fiat_router.post(
|
||||||
|
"/revolut/webhook",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def api_create_revolut_webhook(
|
||||||
|
data: RevolutCreateWebhook,
|
||||||
|
) -> RevolutCreateWebhookResponse:
|
||||||
|
try:
|
||||||
|
webhook = await RevolutWallet.create_webhook(
|
||||||
|
url=data.url,
|
||||||
|
endpoint=data.endpoint,
|
||||||
|
api_secret_key=data.api_secret_key,
|
||||||
|
api_version=data.api_version,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to create Revolut webhook."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
signing_secret = webhook.get("signing_secret")
|
||||||
|
webhook_url = webhook.get("url") or data.url
|
||||||
|
if not signing_secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail="Revolut returned no webhook signing secret."
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_settings = {
|
||||||
|
"revolut_payment_webhook_url": webhook_url,
|
||||||
|
"revolut_webhook_signing_secret": signing_secret,
|
||||||
|
}
|
||||||
|
for key, value in updated_settings.items():
|
||||||
|
await set_settings_field(key, value)
|
||||||
|
update_cached_settings(updated_settings)
|
||||||
|
|
||||||
|
return RevolutCreateWebhookResponse(
|
||||||
|
id=webhook.get("id"),
|
||||||
|
url=webhook_url,
|
||||||
|
events=webhook.get("events") or [],
|
||||||
|
signing_secret=signing_secret,
|
||||||
|
already_exists=webhook.get("already_exists", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@fiat_router.post(
|
@fiat_router.post(
|
||||||
"/{provider}/subscription",
|
"/{provider}/subscription",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -48,6 +50,13 @@ class RevolutSubscriptionReference(BaseModel):
|
|||||||
memo: str | None = None
|
memo: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
REVOLUT_WEBHOOK_EVENTS = [
|
||||||
|
"ORDER_AUTHORISED",
|
||||||
|
"ORDER_COMPLETED",
|
||||||
|
"SUBSCRIPTION_INITIATED",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class RevolutWallet(FiatProvider):
|
class RevolutWallet(FiatProvider):
|
||||||
"""https://developer.revolut.com/docs/merchant"""
|
"""https://developer.revolut.com/docs/merchant"""
|
||||||
|
|
||||||
@@ -313,6 +322,121 @@ class RevolutWallet(FiatProvider):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create_webhook(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
endpoint: str | None = None,
|
||||||
|
api_secret_key: str | None = None,
|
||||||
|
api_version: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not url:
|
||||||
|
raise ValueError("Missing Revolut webhook URL.")
|
||||||
|
cls._validate_webhook_url(url)
|
||||||
|
if not endpoint and not settings.revolut_api_endpoint:
|
||||||
|
raise ValueError("Missing Revolut API endpoint.")
|
||||||
|
if not api_secret_key and not settings.revolut_api_secret_key:
|
||||||
|
raise ValueError("Missing Revolut API secret key.")
|
||||||
|
|
||||||
|
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
|
||||||
|
secret_key = api_secret_key or settings.revolut_api_secret_key
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {secret_key}",
|
||||||
|
"Revolut-Api-Version": api_version or settings.revolut_api_version,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": settings.user_agent,
|
||||||
|
}
|
||||||
|
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
|
||||||
|
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
|
||||||
|
webhooks = await cls._list_webhooks(client)
|
||||||
|
existing = await cls._get_existing_webhook(client, webhooks, url)
|
||||||
|
if existing:
|
||||||
|
existing["already_exists"] = True
|
||||||
|
return existing
|
||||||
|
|
||||||
|
response = await client.post("/api/webhooks", json=payload, timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
|
||||||
|
response = await client.get("/api/webhooks", timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for field in ["webhooks", "data", "items"]:
|
||||||
|
if isinstance(data.get(field), list):
|
||||||
|
return data[field]
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _get_existing_webhook(
|
||||||
|
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
for webhook in webhooks:
|
||||||
|
if cls._normalize_webhook_url(webhook.get("url")) != (
|
||||||
|
cls._normalize_webhook_url(url)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
webhook_id = webhook.get("id")
|
||||||
|
if webhook_id and (
|
||||||
|
not webhook.get("events") or not webhook.get("signing_secret")
|
||||||
|
):
|
||||||
|
response = await client.get(f"/api/webhooks/{webhook_id}", timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
webhook = response.json()
|
||||||
|
|
||||||
|
events = set(webhook.get("events") or [])
|
||||||
|
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
|
||||||
|
if missing_events:
|
||||||
|
raise ValueError(
|
||||||
|
"A Revolut webhook already exists for this URL, but it is "
|
||||||
|
f"missing required events: {', '.join(sorted(missing_events))}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not webhook.get("signing_secret"):
|
||||||
|
raise ValueError(
|
||||||
|
"A Revolut webhook already exists for this URL, but Revolut "
|
||||||
|
"did not return a signing secret."
|
||||||
|
)
|
||||||
|
|
||||||
|
return webhook
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_webhook_url(cls, url: str | None) -> str:
|
||||||
|
return (url or "").strip().rstrip("/")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_webhook_url(cls, url: str) -> None:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if parsed.scheme not in ["http", "https"] or not hostname:
|
||||||
|
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||||
|
|
||||||
|
host = hostname.lower()
|
||||||
|
if host == "localhost" or host.endswith(".localhost"):
|
||||||
|
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||||
|
if host.endswith(".local") or host.endswith(".onion"):
|
||||||
|
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
ip.is_loopback
|
||||||
|
or ip.is_private
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_unspecified
|
||||||
|
):
|
||||||
|
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||||
|
|
||||||
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||||
status = (order.get("state") or "").upper()
|
status = (order.get("state") or "").upper()
|
||||||
if status == "COMPLETED":
|
if status == "COMPLETED":
|
||||||
|
|||||||
+260
-1
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ window.app.component('lnbits-admin-fiat-providers', {
|
|||||||
formAddPaypalUser: '',
|
formAddPaypalUser: '',
|
||||||
formAddSquareUser: '',
|
formAddSquareUser: '',
|
||||||
formAddRevolutUser: '',
|
formAddRevolutUser: '',
|
||||||
|
creatingRevolutWebhook: false,
|
||||||
hideInputToggle: true
|
hideInputToggle: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -87,6 +88,47 @@ window.app.component('lnbits-admin-fiat-providers', {
|
|||||||
}
|
}
|
||||||
this.copyText(url)
|
this.copyText(url)
|
||||||
},
|
},
|
||||||
|
isClearnetWebhookUrl(url) {
|
||||||
|
let parsedUrl
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url)
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsedUrl.hostname.toLowerCase()
|
||||||
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
host === 'localhost' ||
|
||||||
|
host.endsWith('.localhost') ||
|
||||||
|
host.endsWith('.local') ||
|
||||||
|
host.endsWith('.onion')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/^127\./.test(host) ||
|
||||||
|
/^10\./.test(host) ||
|
||||||
|
/^192\.168\./.test(host) ||
|
||||||
|
/^169\.254\./.test(host) ||
|
||||||
|
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
|
||||||
|
host === '0.0.0.0' ||
|
||||||
|
host === '::1'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
notifyRevolutWebhookWarning(message) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message,
|
||||||
|
icon: null,
|
||||||
|
closeBtn: true
|
||||||
|
})
|
||||||
|
},
|
||||||
addStripeAllowedUser() {
|
addStripeAllowedUser() {
|
||||||
const addUser = this.formAddStripeUser || ''
|
const addUser = this.formAddStripeUser || ''
|
||||||
if (
|
if (
|
||||||
@@ -168,6 +210,48 @@ window.app.component('lnbits-admin-fiat-providers', {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
createRevolutWebhook() {
|
||||||
|
const webhookUrl = this.calculateWebhookUrl('revolut')
|
||||||
|
this.formData.revolut_payment_webhook_url = webhookUrl
|
||||||
|
|
||||||
|
if (!this.formData.revolut_api_secret_key) {
|
||||||
|
this.notifyRevolutWebhookWarning(
|
||||||
|
'Add your Revolut API secret key before creating a webhook.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.isClearnetWebhookUrl(webhookUrl)) {
|
||||||
|
this.notifyRevolutWebhookWarning(
|
||||||
|
'Revolut webhook URL must be a clearnet URL.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingRevolutWebhook = true
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/api/v1/fiat/revolut/webhook', null, {
|
||||||
|
url: webhookUrl,
|
||||||
|
endpoint: this.formData.revolut_api_endpoint,
|
||||||
|
api_secret_key: this.formData.revolut_api_secret_key,
|
||||||
|
api_version: this.formData.revolut_api_version
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
const data = response.data
|
||||||
|
this.formData.revolut_payment_webhook_url = data.url
|
||||||
|
this.formData.revolut_webhook_signing_secret = data.signing_secret
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Revolut webhook ${
|
||||||
|
data.already_exists ? 'already exists' : 'created'
|
||||||
|
}${data.id ? `: ${data.id}` : ''}.`,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
.finally(() => {
|
||||||
|
this.creatingRevolutWebhook = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -934,10 +934,11 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<q-expansion-item label="Webhook" default-opened>
|
<q-expansion-item :label="$t('webhook')" default-opened>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
Configure a Revolut Merchant webhook that points to your LNbits
|
Configure a Revolut Merchant webhook that points to your LNbits
|
||||||
server and subscribe to <code>ORDER_AUTHORISED</code>,
|
server. LNbits will create it through the Revolut API and
|
||||||
|
subscribe to <code>ORDER_AUTHORISED</code>,
|
||||||
<code>ORDER_COMPLETED</code>, and
|
<code>ORDER_COMPLETED</code>, and
|
||||||
<code>SUBSCRIPTION_INITIATED</code>.
|
<code>SUBSCRIPTION_INITIATED</code>.
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
@@ -967,13 +968,39 @@
|
|||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-input
|
<div class="row items-center q-gutter-sm q-mt-md">
|
||||||
filled
|
<q-btn
|
||||||
class="q-mt-md"
|
type="button"
|
||||||
:type="hideInputToggle ? 'password' : 'text'"
|
color="primary"
|
||||||
v-model="formData.revolut_webhook_signing_secret"
|
icon="add_link"
|
||||||
label="Webhook signing secret"
|
label="Create webhook"
|
||||||
></q-input>
|
:loading="creatingRevolutWebhook"
|
||||||
|
@click="createRevolutWebhook"
|
||||||
|
></q-btn>
|
||||||
|
<q-chip
|
||||||
|
v-if="formData.revolut_webhook_signing_secret"
|
||||||
|
dense
|
||||||
|
color="positive"
|
||||||
|
text-color="white"
|
||||||
|
icon="verified"
|
||||||
|
>
|
||||||
|
Signing secret saved
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<span v-text="$t('webhook_events_list')"></span>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<code>ORDER_AUTHORISED</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>ORDER_COMPLETED</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>SUBSCRIPTION_INITIATED</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from pytest_mock.plugin import MockerFixture
|
|||||||
|
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
from lnbits.fiat.base import FiatSubscriptionResponse
|
from lnbits.fiat.base import FiatSubscriptionResponse
|
||||||
|
from lnbits.fiat.revolut import REVOLUT_WEBHOOK_EVENTS
|
||||||
|
from lnbits.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
class _UnsetSecret:
|
class _UnsetSecret:
|
||||||
@@ -144,3 +146,82 @@ async def test_fiat_api_connection_token_validates_provider_configuration(
|
|||||||
assert ok.status_code == 200
|
assert ok.status_code == 200
|
||||||
assert ok.json() == {"secret": "tok_live"}
|
assert ok.json() == {"secret": "tok_live"}
|
||||||
assert good_provider.await_count == 1
|
assert good_provider.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fiat_api_creates_revolut_webhook(
|
||||||
|
client: AsyncClient,
|
||||||
|
superuser_token: str,
|
||||||
|
settings: Settings,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
create_webhook = mocker.patch(
|
||||||
|
"lnbits.core.views.fiat_api.RevolutWallet.create_webhook",
|
||||||
|
mocker.AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": "webhook_1",
|
||||||
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
"events": REVOLUT_WEBHOOK_EVENTS,
|
||||||
|
"signing_secret": "whsec_1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/fiat/revolut/webhook",
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
json={
|
||||||
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
"endpoint": "https://sandbox-merchant.revolut.com",
|
||||||
|
"api_secret_key": "secret_1",
|
||||||
|
"api_version": "2026-04-20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"id": "webhook_1",
|
||||||
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
"events": REVOLUT_WEBHOOK_EVENTS,
|
||||||
|
"signing_secret": "whsec_1",
|
||||||
|
"already_exists": False,
|
||||||
|
}
|
||||||
|
create_webhook.assert_awaited_once_with(
|
||||||
|
url="https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
endpoint="https://sandbox-merchant.revolut.com",
|
||||||
|
api_secret_key="secret_1",
|
||||||
|
api_version="2026-04-20",
|
||||||
|
)
|
||||||
|
assert settings.revolut_payment_webhook_url == (
|
||||||
|
"https://lnbits.example/api/v1/callback/revolut"
|
||||||
|
)
|
||||||
|
assert settings.revolut_webhook_signing_secret == "whsec_1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fiat_api_rejects_local_revolut_webhook(
|
||||||
|
client: AsyncClient,
|
||||||
|
superuser_token: str,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
create_webhook = mocker.patch(
|
||||||
|
"lnbits.core.views.fiat_api.RevolutWallet.create_webhook",
|
||||||
|
mocker.AsyncMock(
|
||||||
|
side_effect=ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/fiat/revolut/webhook",
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
json={
|
||||||
|
"url": "http://localhost:5000/api/v1/callback/revolut",
|
||||||
|
"endpoint": "https://sandbox-merchant.revolut.com",
|
||||||
|
"api_secret_key": "secret_1",
|
||||||
|
"api_version": "2026-04-20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == ("Revolut webhook URL must be a clearnet URL.")
|
||||||
|
create_webhook.assert_awaited_once()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from lnbits.fiat.base import (
|
|||||||
FiatStatusResponse,
|
FiatStatusResponse,
|
||||||
FiatSubscriptionPaymentOptions,
|
FiatSubscriptionPaymentOptions,
|
||||||
)
|
)
|
||||||
from lnbits.fiat.revolut import RevolutWallet
|
from lnbits.fiat.revolut import REVOLUT_WEBHOOK_EVENTS, RevolutWallet
|
||||||
from lnbits.fiat.square import SquareWallet
|
from lnbits.fiat.square import SquareWallet
|
||||||
from lnbits.settings import Settings
|
from lnbits.settings import Settings
|
||||||
from tests.helpers import get_random_string
|
from tests.helpers import get_random_string
|
||||||
@@ -899,6 +899,110 @@ async def test_revolut_wallet_cancel_subscription(settings: Settings):
|
|||||||
assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123/cancel"
|
assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123/cancel"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_revolut_wallet_create_webhook(mocker: MockerFixture):
|
||||||
|
client = MockHTTPClient(
|
||||||
|
[
|
||||||
|
MockHTTPResponse({"webhooks": []}),
|
||||||
|
MockHTTPResponse(
|
||||||
|
{
|
||||||
|
"id": "webhook_1",
|
||||||
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
"events": REVOLUT_WEBHOOK_EVENTS,
|
||||||
|
"signing_secret": "whsec_1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
async_client = mocker.patch("lnbits.fiat.revolut.httpx.AsyncClient")
|
||||||
|
async_client.return_value = client
|
||||||
|
|
||||||
|
response = await RevolutWallet.create_webhook(
|
||||||
|
url="https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
endpoint="https://sandbox-merchant.revolut.com",
|
||||||
|
api_secret_key="revolut-secret",
|
||||||
|
api_version="2026-04-20",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response["signing_secret"] == "whsec_1"
|
||||||
|
async_client.assert_called_once()
|
||||||
|
assert async_client.call_args.kwargs["base_url"] == (
|
||||||
|
"https://sandbox-merchant.revolut.com"
|
||||||
|
)
|
||||||
|
assert async_client.call_args.kwargs["headers"]["Authorization"] == (
|
||||||
|
"Bearer revolut-secret"
|
||||||
|
)
|
||||||
|
assert client.calls == [
|
||||||
|
(
|
||||||
|
"/api/webhooks",
|
||||||
|
{
|
||||||
|
"timeout": 15,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/webhooks",
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
"events": REVOLUT_WEBHOOK_EVENTS,
|
||||||
|
},
|
||||||
|
"timeout": 15,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_revolut_wallet_reuses_existing_webhook(mocker: MockerFixture):
|
||||||
|
client = MockHTTPClient(
|
||||||
|
[
|
||||||
|
MockHTTPResponse(
|
||||||
|
{
|
||||||
|
"webhooks": [
|
||||||
|
{
|
||||||
|
"id": "webhook_1",
|
||||||
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
"events": REVOLUT_WEBHOOK_EVENTS,
|
||||||
|
"signing_secret": "whsec_1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
async_client = mocker.patch("lnbits.fiat.revolut.httpx.AsyncClient")
|
||||||
|
async_client.return_value = client
|
||||||
|
|
||||||
|
response = await RevolutWallet.create_webhook(
|
||||||
|
url="https://lnbits.example/api/v1/callback/revolut",
|
||||||
|
endpoint="https://sandbox-merchant.revolut.com",
|
||||||
|
api_secret_key="revolut-secret",
|
||||||
|
api_version="2026-04-20",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response["already_exists"] is True
|
||||||
|
assert response["signing_secret"] == "whsec_1"
|
||||||
|
assert client.calls == [
|
||||||
|
(
|
||||||
|
"/api/webhooks",
|
||||||
|
{
|
||||||
|
"timeout": 15,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_revolut_wallet_rejects_local_webhook_url():
|
||||||
|
with pytest.raises(ValueError, match="clearnet URL"):
|
||||||
|
await RevolutWallet.create_webhook(
|
||||||
|
url="http://localhost:5000/api/v1/callback/revolut",
|
||||||
|
endpoint="https://sandbox-merchant.revolut.com",
|
||||||
|
api_secret_key="revolut-secret",
|
||||||
|
api_version="2026-04-20",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_check_revolut_signature():
|
def test_check_revolut_signature():
|
||||||
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
|
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
|
||||||
timestamp = str(int(time.time()))
|
timestamp = str(int(time.time()))
|
||||||
@@ -908,6 +1012,55 @@ def test_check_revolut_signature():
|
|||||||
check_revolut_signature(payload, sig, timestamp, secret)
|
check_revolut_signature(payload, sig, timestamp, secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_revolut_signature_millisecond_timestamp():
|
||||||
|
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
|
||||||
|
timestamp = str(int(time.time() * 1000))
|
||||||
|
secret = "revolut-secret"
|
||||||
|
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
check_revolut_signature(payload, sig, timestamp, secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_revolut_signature_v1_header():
|
||||||
|
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
|
||||||
|
timestamp = str(int(time.time() * 1000))
|
||||||
|
secret = "revolut-secret"
|
||||||
|
signed_payload = b"v1." + timestamp.encode() + b"." + payload
|
||||||
|
sig = "v1=" + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
check_revolut_signature(payload, sig, timestamp, secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_revolut_signature_multiple_v1_headers():
|
||||||
|
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
|
||||||
|
timestamp = str(int(time.time() * 1000))
|
||||||
|
secret = "revolut-secret"
|
||||||
|
signed_payload = b"v1." + timestamp.encode() + b"." + payload
|
||||||
|
valid_sig = (
|
||||||
|
"v1=" + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
|
||||||
|
)
|
||||||
|
sig_header = f"v1=deadbeef,{valid_sig}"
|
||||||
|
|
||||||
|
check_revolut_signature(payload, sig_header, timestamp, secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_revolut_signature_docs_vector():
|
||||||
|
payload = (
|
||||||
|
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
|
||||||
|
b'"new_state":"completed","old_state":"pending",'
|
||||||
|
b'"request_id":"app_charges-9f5d5eb3-1e06-46c5-b1c0-3914763e0bcb"},'
|
||||||
|
b'"event":"TransactionStateChanged",'
|
||||||
|
b'"timestamp":"2023-05-09T16:36:38.028960Z"}'
|
||||||
|
)
|
||||||
|
timestamp = "1683650202360"
|
||||||
|
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
|
||||||
|
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
|
||||||
|
|
||||||
|
check_revolut_signature(
|
||||||
|
payload, sig, timestamp, secret, tolerance_seconds=100000000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_fiat_service_fee(settings: Settings):
|
async def test_fiat_service_fee(settings: Settings):
|
||||||
# settings.stripe_limits.service_min_amount_sats = 0
|
# settings.stripe_limits.service_min_amount_sats = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user