Compare commits

...
Author SHA1 Message Date
Vlad Stan a963b61cc8 fix: lint 2026-05-22 11:52:32 +03:00
Arc d66e22a83e Admin only toggle + remove annoying password browser thing 2026-05-21 14:18:28 +01:00
Arc 882d861f71 coming soon for sbscriptions 2026-05-21 13:56:09 +01:00
Vlad Stan 186aefad22 fix: api test 2026-05-21 14:21:49 +03:00
Vlad Stan 7badf98bb7 fix: unit tests 2026-05-21 14:12:43 +03:00
Vlad Stan 2ee098ddb1 fix: subscription_request_id 2026-05-21 14:12:43 +03:00
Vlad Stan 3723b6510d refactor: better ID check 2026-05-21 14:12:43 +03:00
Vlad Stan 03161f8e9b feat: handle subscriptions 2026-05-21 14:12:43 +03:00
Vlad Stan 5b7f9fa255 fix: remove customer_id 2026-05-21 14:12:43 +03:00
Vlad Stan 0c74adc494 feat: find customer by email 2026-05-21 14:12:43 +03:00
Vlad Stan ea6965905e fix: find customer by email 2026-05-21 14:12:43 +03:00
Vlad Stan ea8228fc84 feat: create customer id from email if missing 2026-05-21 14:12:43 +03:00
Vlad Stan 49adec6488 chore: bundle 2026-05-21 14:12:43 +03:00
ArcandVlad Stan c122bb45f4 Do the separator 🎵 2026-05-21 14:12:43 +03:00
751bd42169 feat: Adds revolut checkout and subscriptions (#3968)
Co-authored-by: alan <alan@lnbits.com>
2026-05-21 14:12:43 +03:00
Vlad Stan c4ee3a7c6b feat: introduce external_id 2026-05-21 14:12:43 +03:00
Vlad Stan 9a4e55f469 refactor: move private method to the bottom 2026-05-21 14:12:43 +03:00
Vlad Stan f45736e70e refactor: extract method 2026-05-21 14:12:43 +03:00
Vlad Stan ef39d02bd2 fix: simplify expected event types 2026-05-21 14:12:43 +03:00
Vlad Stan d66c7f7992 fix: plan 2026-05-21 14:12:43 +03:00
Vlad Stan 2abedccbc8 feat: first subscription 2026-05-21 14:12:43 +03:00
Vlad Stan cc498a7969 fix: webhook url for signature check 2026-05-21 14:12:43 +03:00
Vlad Stan cd57bf66fe fix: lint 2026-05-21 14:12:43 +03:00
Vlad Stan d921af3476 feat: basic square integration 2026-05-21 14:12:43 +03:00
23 changed files with 4542 additions and 24 deletions
+1
View File
@@ -292,6 +292,7 @@ async def create_payment(
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [], labels=data.labels or [],
external_id=data.external_id,
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
+13
View File
@@ -802,3 +802,16 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status. Used for account activation status.
""" """
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true") await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
+28
View File
@@ -13,6 +13,7 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import ( from lnbits.fiat.base import (
FiatPaymentStatus, FiatPaymentStatus,
) )
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentStatus, PaymentStatus,
@@ -53,6 +54,11 @@ class CreatePayment(BaseModel):
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel): class Payment(BaseModel):
@@ -77,6 +83,11 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = [] labels: list[str] = []
extra: dict = {} extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@@ -151,6 +162,7 @@ class PaymentFilters(FilterModel):
"status", "status",
"time", "time",
"labels", "labels",
"external_id",
] ]
__sort_fields__ = [ __sort_fields__ = [
@@ -161,11 +173,13 @@ class PaymentFilters(FilterModel):
"memo", "memo",
"time", "time",
"tag", "tag",
"external_id",
] ]
status: str | None status: str | None
tag: str | None tag: str | None
checking_id: str | None checking_id: str | None
external_id: str | None
amount: int amount: int
fee: int fee: int
memo: str | None memo: str | None
@@ -249,6 +263,7 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):
@@ -263,6 +278,10 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported") raise ValueError("The provided unit is not supported")
return v return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel): class PaymentsStatusCount(BaseModel):
incoming: int = 0 incoming: int = 0
@@ -301,3 +320,12 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel): class UpdatePaymentLabels(BaseModel):
labels: list[str] = [] labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
+77
View File
@@ -2,6 +2,7 @@ import hashlib
import hmac import hmac
import json import json
import time import time
from base64 import b64encode
import httpx import httpx
from loguru import logger from loguru import logger
@@ -169,6 +170,82 @@ async def verify_paypal_webhook(headers, payload: bytes):
raise ValueError("PayPal webhook cannot be verified.") from exc raise ValueError("PayPal webhook cannot be verified.") from exc
def check_square_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
notification_url: str | None,
):
if not sig_header:
logger.warning("Square signature header is missing.")
raise ValueError("Square signature header is missing.")
if not secret:
logger.warning("Square webhook signature key is not set.")
raise ValueError("Square webhook cannot be verified.")
if not notification_url:
logger.warning("Square webhook notification URL is not set.")
raise ValueError("Square webhook cannot be verified.")
signed_payload = notification_url.encode() + payload
computed_signature = b64encode(
hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).digest()
).decode()
if hmac.compare_digest(computed_signature, sig_header) is not True:
logger.warning("Square signature verification failed.")
raise ValueError("Square signature verification failed.")
def check_revolut_signature(
payload: bytes,
sig_header: str | None,
timestamp_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Revolut signature header is missing.")
raise ValueError("Revolut signature header is missing.")
if not timestamp_header:
logger.warning("Revolut timestamp header is missing.")
raise ValueError("Revolut timestamp header is missing.")
if not secret:
logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.")
try:
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.") from exc
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
digest = hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).hexdigest()
expected_signature = f"v1={digest}"
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any(
hmac.compare_digest(expected_signature, provided)
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
raise ValueError("Revolut signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus: async def test_connection(provider: str) -> SimpleStatus:
""" """
Test the connection to Stripe by checking if the API key is valid. Test the connection to Stripe by checking if the API key is valid.
+9
View File
@@ -64,6 +64,7 @@ async def pay_invoice(
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
@@ -97,6 +98,7 @@ async def pay_invoice(
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels, labels=labels,
external_id=external_id,
) )
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -131,6 +133,10 @@ async def create_fiat_invoice(
raise ValueError( raise ValueError(
f"Fiat provider '{fiat_provider_name}' is not enabled.", f"Fiat provider '{fiat_provider_name}' is not enabled.",
) )
if settings.fiat_providers_admin_only:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not settings.is_admin_user(wallet.user):
raise ValueError("Fiat providers are available to admins only.")
if invoice_data.unit == "sat": if invoice_data.unit == "sat":
raise ValueError("Fiat provider cannot be used with satoshis.") raise ValueError("Fiat provider cannot be used with satoshis.")
@@ -217,6 +223,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
labels=data.labels, labels=data.labels,
external_id=data.external_id,
conn=conn, conn=conn,
) )
@@ -258,6 +265,7 @@ async def create_invoice(
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@@ -342,6 +350,7 @@ async def create_invoice(
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels, labels=labels,
external_id=external_id,
) )
payment = await create_payment( payment = await create_payment(
+432 -1
View File
@@ -4,17 +4,30 @@ from fastapi import APIRouter, Request
from loguru import logger from loguru import logger
from lnbits.core.crud.payments import ( from lnbits.core.crud.payments import (
get_payments,
get_standalone_payment, get_standalone_payment,
update_payment,
) )
from lnbits.core.models import Payment, PaymentFilters
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_fiat_status, check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook, verify_paypal_webhook,
) )
from lnbits.core.services.payments import create_fiat_invoice from lnbits.core.services.payments import (
create_fiat_invoice,
create_wallet_invoice,
service_fee_fiat,
)
from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import settings from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"]) callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -50,6 +63,41 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "square":
payload = await request.body()
sig_header = request.headers.get("x-square-hmacsha256-signature")
check_square_signature(
payload,
sig_header,
settings.square_webhook_signature_key,
settings.square_payment_webhook_url,
)
event = await request.json()
await handle_square_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "revolut":
payload = await request.body()
sig_header = request.headers.get("Revolut-Signature")
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
check_revolut_signature(
payload,
sig_header,
timestamp_header,
settings.revolut_webhook_signing_secret,
)
event = await request.json()
await handle_revolut_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
@@ -280,3 +328,386 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
except (json.JSONDecodeError, IndexError) as e: except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}") logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions() return FiatSubscriptionPaymentOptions()
async def handle_square_event(event: dict):
event_id = event.get("event_id") or event.get("id", "")
event_type = event.get("type", "")
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
if event_type == "payment.updated":
await _handle_square_payment_event(event)
return
if event_type == "invoice.payment_made":
await _handle_square_invoice_payment_made(event)
return
logger.warning(f"Unhandled Square event type: '{event_type}'.")
async def handle_revolut_event(event: dict):
event_type = event.get("event", "")
order_id = event.get("order_id")
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
if not order_id:
logger.warning("Revolut event missing order_id.")
return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if not payment:
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id)
return
await check_fiat_status(payment)
return
if event_type == "SUBSCRIPTION_INITIATED":
await _handle_revolut_subscription_initiated(event)
return
if event_type in [
"SUBSCRIPTION_CANCELLED",
"SUBSCRIPTION_FINISHED",
"SUBSCRIPTION_OVERDUE",
]:
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
return
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _handle_revolut_subscription_initiated(event: dict):
subscription_id = event.get("subscription_id")
if not subscription_id:
subscription_id = event.get("id")
if not subscription_id:
logger.warning("Revolut subscription event missing subscription_id.")
return
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
subscription = await fiat_provider.get_subscription(subscription_id)
await _handle_revolut_subscription(subscription, fiat_provider)
async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
return None
return fiat_provider
async def _handle_revolut_subscription(
subscription: dict, fiat_provider: RevolutWallet
):
subscription_id = subscription.get("id")
if not subscription_id:
logger.warning("Revolut subscription missing id.")
return
reference = fiat_provider.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference:
logger.warning("Revolut subscription event missing LNbits metadata.")
return
cycle_id = subscription.get("current_cycle_id")
if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id:
logger.warning("Revolut subscription cycle missing order_id.")
return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment:
if existing_payment.external_id != subscription_id:
existing_payment.external_id = subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency:
raise ValueError("Revolut subscription order missing amount or currency.")
extra = {
**(reference.extra or {}),
"subscription_request_id": reference.subscription_request_id,
"fiat_method": "subscription",
"tag": reference.tag,
"subscription": {
"checking_id": f"order_{order_id}",
"payment_request": order.get("checkout_url") or "",
},
}
lnbits_payment = await _create_revolut_subscription_payment(
wallet_id=reference.wallet_id,
amount_minor=amount_minor,
currency=currency,
memo=reference.memo or "",
extra=extra,
order_id=order_id,
payment_request=order.get("checkout_url") or "",
subscription_id=subscription_id,
)
await check_fiat_status(lnbits_payment)
async def _handle_revolut_subscription_order_paid(order_id: str):
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
order = await fiat_provider.get_order(order_id)
if order.get("type") != "payment" or order.get("state") != "completed":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return
channel_data = order.get("channel_data") or {}
subscription_id = channel_data.get("subscription_id")
if not subscription_id:
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
if subscription.get("state") != "active":
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return
await _handle_revolut_subscription_initiated(subscription)
async def _create_revolut_subscription_payment(
wallet_id: str,
amount_minor: int,
currency: str,
memo: str,
extra: dict,
order_id: str,
payment_request: str,
subscription_id: str,
) -> Payment:
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
payment = await create_wallet_invoice(
wallet_id,
CreateInvoice(
unit=currency,
amount=amount,
memo=memo,
extra=extra,
internal=True,
external_id=subscription_id,
),
)
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
payment.fiat_provider = "revolut"
payment.extra["fiat_checking_id"] = f"order_{order_id}"
payment.extra["fiat_payment_request"] = payment_request
checking_id = f"fiat_revolut_order_{order_id}"
await update_payment(payment, checking_id)
payment.checking_id = checking_id
return payment
async def _handle_square_payment_event(event: dict):
payment = _square_extract_payment(event)
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if payment_options.wallet_id:
if not _square_payment_is_completed(payment):
logger.debug("Square subscription payment is not completed yet.")
return
await _handle_square_subscription_payment(payment, payment_options)
return
order_id = payment.get("order_id")
if not order_id:
logger.warning("Square payment event missing order_id.")
return
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
if not lnbits_payment:
logger.warning(f"No payment found for Square order: '{order_id}'.")
return
await check_fiat_status(lnbits_payment)
async def _handle_square_invoice_payment_made(event: dict):
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
order_id = invoice.get("order_id")
if not order_id:
logger.warning("Square invoice.payment_made event missing order_id.")
return
subscription_id = invoice.get("subscription_id")
fiat_provider = await get_fiat_provider("square")
if not isinstance(fiat_provider, SquareWallet):
logger.warning("Square fiat provider is not configured.")
return
payment = await fiat_provider.get_payment_for_order(order_id)
if not payment:
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
return
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = (
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
if payment_id
else None
)
if not stored_payment and subscription_id:
stored_payments = await get_payments(
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
)
)
stored_payment = stored_payments[0] if stored_payments else None
if stored_payment:
payment_options = _square_payment_options_from_payment(stored_payment)
else:
logger.warning("Square subscription payment missing LNbits metadata.")
return
await _handle_square_subscription_payment(
payment,
payment_options,
invoice.get("public_url") or "",
square_subscription_id=subscription_id,
)
async def _handle_square_subscription_payment(
payment: dict,
payment_options: FiatSubscriptionPaymentOptions,
payment_request: str = "",
square_subscription_id: str | None = None,
):
amount_money = payment.get("amount_money") or {}
amount = amount_money.get("amount")
currency = (amount_money.get("currency") or "").upper()
payment_id = payment.get("id")
if amount is None or not currency or not payment_id:
raise ValueError("Square subscription payment event missing payment amount.")
wallet_id = payment_options.wallet_id
if not wallet_id:
raise ValueError("Square subscription payment event missing wallet_id.")
checking_id = f"payment_{payment_id}"
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
if existing_payment:
if (
square_subscription_id
and existing_payment.external_id != square_subscription_id
):
existing_payment.external_id = square_subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
square_subscription_id = square_subscription_id or (
payment_options.extra or {}
).get("square_subscription_id")
extra = {
**(payment_options.extra or {}),
"subscription_request_id": payment_options.subscription_request_id,
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": checking_id,
"payment_request": payment_request,
},
}
lnbits_payment = await create_fiat_invoice(
wallet_id=wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount / 100,
memo=payment_options.memo or "",
extra=extra,
fiat_provider="square",
external_id=square_subscription_id,
),
)
await check_fiat_status(lnbits_payment)
def _square_payment_options_from_payment(
payment: Payment,
) -> FiatSubscriptionPaymentOptions:
extra = payment.extra or {}
return FiatSubscriptionPaymentOptions(
wallet_id=payment.wallet_id,
tag=extra.get("tag") or payment.tag,
subscription_request_id=extra.get("subscription_request_id"),
extra=extra,
memo=payment.memo,
)
def _square_extract_payment(event: dict) -> dict:
event_object = event.get("data", {}).get("object", {})
return event_object.get("payment") or event_object
def _square_payment_is_completed(payment: dict) -> bool:
return (payment.get("status") or "").upper() == "COMPLETED"
def _square_payment_note(payment: dict) -> str:
return payment.get("note") or payment.get("payment_note") or ""
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
if not isinstance(meta, list):
return FiatSubscriptionPaymentOptions()
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError, TypeError):
return FiatSubscriptionPaymentOptions()
+67 -1
View File
@@ -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
View File
@@ -263,6 +263,7 @@ async def api_payments_create(
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels, labels=invoice_data.labels,
external_id=invoice_data.external_id,
) )
return payment return payment
+6
View File
@@ -9,6 +9,8 @@ from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings from lnbits.settings import settings
from .paypal import PayPalWallet from .paypal import PayPalWallet
from .revolut import RevolutWallet
from .square import SquareWallet
from .stripe import StripeWallet from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat") fiat_module = importlib.import_module("lnbits.fiat")
@@ -17,6 +19,8 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum): class FiatProviderType(Enum):
stripe = "StripeWallet" stripe = "StripeWallet"
paypal = "PayPalWallet" paypal = "PayPalWallet"
square = "SquareWallet"
revolut = "RevolutWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None: async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -52,5 +56,7 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [ __all__ = [
"PayPalWallet", "PayPalWallet",
"RevolutWallet",
"SquareWallet",
"StripeWallet", "StripeWallet",
] ]
+4
View File
@@ -95,6 +95,10 @@ class FiatSubscriptionPaymentOptions(BaseModel):
description="Unique ID that can be used to identify the subscription request." description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.", "If not provided, one will be generated.",
) )
customer_email: str | None = Field(
default=None,
description="The customer email to use for the subscription.",
)
tag: str | None = Field( tag: str | None = Field(
default=None, default=None,
description="Payments created by the recurring subscription" description="Payments created by the recurring subscription"
+637
View File
@@ -0,0 +1,637 @@
import asyncio
import ipaddress
import json
from collections.abc import AsyncGenerator
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from urllib.parse import urlparse
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class RevolutCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class RevolutCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: RevolutCheckoutOptions | None = None
class RevolutSubscriptionReference(BaseModel):
wallet_id: str
tag: str | None = None
subscription_request_id: str | None = None
extra: dict[str, Any] | None = None
memo: str | None = None
REVOLUT_WEBHOOK_EVENTS = [
"ORDER_AUTHORISED",
"ORDER_COMPLETED",
"SUBSCRIPTION_INITIATED",
]
ZERO_DECIMAL_CURRENCIES = {
"BIF",
"CLP",
"DJF",
"GNF",
"ISK",
"JPY",
"KMF",
"KRW",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
}
THREE_DECIMAL_CURRENCIES = {
"BHD",
"IQD",
"JOD",
"KWD",
"LYD",
"OMR",
"TND",
}
REVOLUT_CUSTOMER_LIST_LIMIT = 500
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
REVOLUT_REQUEST_TIMEOUT = 30
class RevolutWallet(FiatProvider):
"""https://developer.revolut.com/docs/merchant"""
def __init__(self):
logger.debug("Initializing RevolutWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.revolut_api_endpoint:
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
if not settings.revolut_api_secret_key:
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
"Revolut-Api-Version": settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("RevolutWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Revolut wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(
ok=False, error_message="Invalid Revolut options"
)
amount_minor = self.amount_to_minor_units(amount, currency)
checkout = opts.checkout or RevolutCheckoutOptions()
success_url = (
checkout.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
)
payload = {
"amount": amount_minor,
"currency": currency.upper(),
"description": checkout.description or memo or "LNbits Invoice",
"redirect_url": success_url,
"metadata": {
**checkout.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
},
}
try:
r = await self.client.post(
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
checkout_url = data.get("checkout_url")
if not order_id or not checkout_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=checkout_url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if quantity != 1:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions do not support quantity.",
)
wallet_id = payment_options.wallet_id
if not wallet_id:
return FiatSubscriptionResponse(
ok=False, error_message="Wallet ID is required."
)
extra = payment_options.extra or {}
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
reference = RevolutSubscriptionReference(
wallet_id=wallet_id,
tag=payment_options.tag,
subscription_request_id=payment_options.subscription_request_id,
extra=extra,
memo=payment_options.memo,
)
payload: dict[str, Any] = {
"plan_variation_id": subscription_id,
"external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": (
payment_options.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
),
}
if extra.get("trial_duration"):
payload["trial_duration"] = extra["trial_duration"]
headers = {
**self.headers,
"Idempotency-Key": payment_options.subscription_request_id,
}
try:
customer_id, customer_error = await self._get_subscription_customer_id(
payment_options
)
if not customer_id:
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
payload["customer_id"] = customer_id
r = await self.client.post(
"/api/subscriptions",
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
data = r.json()
revolut_subscription_id = data.get("id")
setup_order_id = data.get("setup_order_id")
if not revolut_subscription_id or not setup_order_id:
return FiatSubscriptionResponse(
ok=False,
error_message=(
"Server error: missing subscription id or setup order id"
),
)
setup_order = await self.get_order(setup_order_id)
checkout_url = setup_order.get("checkout_url")
if not checkout_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing setup checkout url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Revolut does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
order_id = self._normalize_revolut_id(checking_id)
return self._status_from_order(await self.get_order(order_id))
except Exception as exc:
logger.debug(f"Error getting Revolut invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Revolut does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Revolut does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _normalize_revolut_id(self, checking_id: str) -> str:
value = (
checking_id.replace("fiat_revolut_", "", 1)
if checking_id.startswith("fiat_revolut_")
else checking_id
)
return value.replace("order_", "", 1) if value.startswith("order_") else value
async def get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription_cycle(
self, subscription_id: str, cycle_id: str
) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
async def _get_subscription_customer_id(
self, payment_options: FiatSubscriptionPaymentOptions
) -> tuple[str | None, str | None]:
if not payment_options.customer_email:
return (
None,
"Revolut subscriptions require customer_email.",
)
customer = await self._get_customer_by_email(payment_options.customer_email)
customer_id = customer.get("id") if customer else None
if customer_id:
return customer_id, None
customer = await self._create_customer(payment_options.customer_email)
customer_id = customer.get("id")
if not customer_id:
return None, "Server error: missing customer id"
return customer_id, None
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
page_token = None
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
customer_page = await self._list_customers(page_token=page_token)
customer = _find_customer_by_email(customer_page["customers"], email)
if customer:
return customer
page_token = customer_page.get("next_page_token")
if not page_token:
return None
return None
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
if page_token:
params["page_token"] = page_token
r = await self.client.get(
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return _extract_customer_page(r.json())
async def _create_customer(self, email: str) -> dict[str, Any]:
r = await self.client.post(
"/api/customers",
json={"email": email},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
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=REVOLUT_REQUEST_TIMEOUT
)
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=REVOLUT_REQUEST_TIMEOUT)
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=REVOLUT_REQUEST_TIMEOUT
)
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:
status = (order.get("state") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELLED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
@classmethod
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
scale = Decimal(10) ** cls.currency_exponent(currency)
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
@classmethod
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
scale = Decimal(10) ** cls.currency_exponent(currency)
return float(Decimal(amount) / scale)
@classmethod
def currency_exponent(cls, currency: str) -> int:
normalized = currency.upper()
if normalized in ZERO_DECIMAL_CURRENCIES:
return 0
if normalized in THREE_DECIMAL_CURRENCIES:
return 3
return 2
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> RevolutCreateInvoiceOptions | None:
try:
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Revolut options: {e}")
return None
def _serialize_subscription_reference(
self, reference: RevolutSubscriptionReference
) -> str:
payload = reference.dict(exclude_none=True)
serialized = json.dumps(payload, separators=(",", ":"))
if len(serialized) > 1024:
raise ValueError("Revolut subscription external_reference is too long.")
return serialized
def deserialize_subscription_reference(
self, external_reference: str | None
) -> RevolutSubscriptionReference | None:
if not external_reference:
return None
try:
return RevolutSubscriptionReference.parse_obj(
json.loads(external_reference)
)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning(exc)
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.revolut_api_endpoint),
str(settings.revolut_api_secret_key),
str(settings.revolut_api_version),
str(settings.revolut_webhook_signing_secret),
]
)
def _extract_customer_page(data: Any) -> dict[str, Any]:
if isinstance(data, list):
return {"customers": _filter_customer_list(data)}
if isinstance(data, dict):
for field in ["customers", "data", "items"]:
customers = data.get(field)
if isinstance(customers, list):
return {
"customers": _filter_customer_list(customers),
"next_page_token": data.get("next_page_token"),
}
return {"customers": []}
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
return [customer for customer in customers if isinstance(customer, dict)]
def _find_customer_by_email(
customers: list[dict[str, Any]], email: str
) -> dict[str, Any] | None:
normalized_email = email.casefold()
for customer in customers:
if str(customer.get("email") or "").casefold() == normalized_email:
return customer
return None
+620
View File
@@ -0,0 +1,620 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
FiatMethod = Literal["checkout", "subscription"]
class SquareCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
line_item_name: str | None = None
class SquareSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class SquareCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
checkout: SquareCheckoutOptions | None = None
subscription: SquareSubscriptionOptions | None = None
class SquareSubscriptionCheckoutInfo(BaseModel):
plan_variation_id: str
price_money: dict[str, Any]
class SquareWallet(FiatProvider):
"""https://developer.squareup.com/reference/square"""
def __init__(self):
logger.debug("Initializing SquareWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.square_api_endpoint:
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
if not settings.square_access_token:
raise ValueError("Cannot initialize SquareWallet: missing access token.")
if not settings.square_location_id:
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
self.location_id = settings.square_location_id
self.headers = {
"Authorization": f"Bearer {settings.square_access_token}",
"Square-Version": settings.square_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("SquareWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Square wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
if opts.fiat_method == "subscription":
return self._create_subscription_invoice(opts.subscription)
return await self._create_checkout_invoice(
amount=amount,
payment_hash=payment_hash,
currency=currency,
opts=opts,
memo=memo,
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if settings.lnbits_running:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not supported for Square."
)
success_url = (
payment_options.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
checkout_info = await self._get_subscription_checkout_info(subscription_id)
metadata = self._serialize_metadata(payment_options)
payload = {
"idempotency_key": payment_options.subscription_request_id,
"description": metadata,
"quick_pay": {
"name": (payment_options.memo or "LNbits Subscription")[:255],
"price_money": checkout_info.price_money,
"location_id": self.location_id,
},
"checkout_options": {
"redirect_url": success_url,
"subscription_plan_id": checkout_info.plan_variation_id,
},
"payment_note": metadata,
}
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
url = payment_link.get("url")
if not url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
square_subscription_id = await self._get_square_subscription_id(
subscription_id, correlation_id
)
r = await self.client.post(
f"/v2/subscriptions/{square_subscription_id}/cancel"
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Square does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
square_id = self._normalize_square_id(checking_id)
if square_id.startswith("payment_"):
payment_id = square_id.replace("payment_", "", 1)
return await self._get_payment_status(payment_id)
order_id = (
square_id.replace("order_", "", 1)
if square_id.startswith("order_")
else square_id
)
return await self._get_order_status(order_id)
except Exception as exc:
logger.debug(f"Error getting Square invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Square does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Square does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if payment_id:
return await self._get_payment_status(payment_id)
if (order.get("state") or "").upper() == "CANCELED":
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/orders/{order_id}")
r.raise_for_status()
return r.json().get("order") or {}
async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if not payment_id:
return None
return await self._get_payment(payment_id)
def _payment_id_from_order(self, order: dict[str, Any]) -> str | None:
tenders = order.get("tenders") or []
for tender in tenders:
payment_id = tender.get("payment_id")
if payment_id:
return payment_id
return None
async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus:
return self._status_from_payment(await self._get_payment(payment_id))
async def _get_payment(self, payment_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/payments/{payment_id}")
r.raise_for_status()
return r.json().get("payment") or {}
async def _get_subscription_checkout_info(
self, subscription_plan_id: str
) -> SquareSubscriptionCheckoutInfo:
catalog_object = await self._get_catalog_object(subscription_plan_id)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
return await self._get_plan_checkout_info(catalog_object)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
price_money = await self._get_subscription_price_money(
catalog_object,
)
plan_variation_id = catalog_object.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
raise ValueError(
"Square subscription ID must be a plan ID or plan variation ID."
)
async def _get_plan_checkout_info(
self, catalog_object: dict[str, Any]
) -> SquareSubscriptionCheckoutInfo:
plan_data = catalog_object.get("subscription_plan_data") or {}
plan_variations = plan_data.get("subscription_plan_variations") or []
eligible_item_ids = plan_data.get("eligible_item_ids") or []
plan_variation = next(
(
variation
for variation in plan_variations
if not variation.get("is_deleted")
),
None,
)
if not plan_variation:
raise ValueError("Square subscription plan is missing a variation.")
price_money = await self._get_subscription_price_money(
plan_variation,
eligible_item_ids=eligible_item_ids,
)
plan_variation_id = plan_variation.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/catalog/object/{object_id}")
r.raise_for_status()
return r.json().get("object") or {}
async def _get_subscription_price_money(
self,
plan_variation: dict[str, Any],
eligible_item_ids: list[str] | None = None,
) -> dict[str, Any]:
variation_data = plan_variation.get("subscription_plan_variation_data") or {}
phases = variation_data.get("phases") or []
for phase in phases:
pricing = phase.get("pricing") or {}
price_money = pricing.get("price_money") or phase.get(
"recurring_price_money"
)
parsed_price_money = self._parse_price_money(price_money)
if parsed_price_money:
return parsed_price_money
if pricing.get("type") == "RELATIVE":
return await self._get_relative_subscription_price_money(
eligible_item_ids or []
)
raise ValueError("Square subscription plan variation is missing price_money.")
async def _get_relative_subscription_price_money(
self, eligible_item_ids: list[str]
) -> dict[str, Any]:
if len(eligible_item_ids) != 1:
raise ValueError(
"Square relative subscription plan must have exactly one item."
)
item = await self._get_catalog_object(eligible_item_ids[0])
item_variations: list[dict[str, Any]] = []
if item.get("type") == "ITEM":
item_variations = (item.get("item_data") or {}).get("variations") or []
elif item.get("type") == "ITEM_VARIATION":
item_variations = [item]
item_variation = next(
(
variation
for variation in item_variations
if not variation.get("is_deleted")
),
None,
)
if not item_variation:
raise ValueError("Square subscription item is missing a variation.")
price_money = self._parse_price_money(
(item_variation.get("item_variation_data") or {}).get("price_money")
)
if price_money:
return price_money
raise ValueError("Square subscription item variation is missing price_money.")
def _parse_price_money(
self, price_money: dict[str, Any] | None
) -> dict[str, Any] | None:
if (
price_money
and price_money.get("amount") is not None
and price_money.get("currency")
):
return {
"amount": int(price_money["amount"]),
"currency": price_money["currency"].upper(),
}
return None
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
status = (payment.get("status") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _create_checkout_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
opts: SquareCreateInvoiceOptions,
memo: str | None = None,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
co = opts.checkout or SquareCheckoutOptions()
success_url = (
co.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
metadata = {
**co.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
}
payload = {
"idempotency_key": payment_hash,
"order": {
"location_id": self.location_id,
"metadata": metadata,
"line_items": [
{
"name": line_item_name,
"quantity": "1",
"base_price_money": {
"amount": amount_cents,
"currency": currency.upper(),
},
}
],
},
"checkout_options": {"redirect_url": success_url},
}
if memo:
payload["payment_note"] = memo[:500]
try:
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
order_id = payment_link.get("order_id")
url = payment_link.get("url")
if not order_id or not url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _create_subscription_invoice(
self, opts: SquareSubscriptionOptions | None
) -> FiatInvoiceResponse:
term = opts or SquareSubscriptionOptions()
checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}"
return FiatInvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=term.payment_request or "",
)
def _normalize_square_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_square_", "", 1)
if checking_id.startswith("fiat_square_")
else checking_id
)
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> SquareCreateInvoiceOptions | None:
try:
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Square options: {e}")
return None
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
extra_link = None
if payment_options.extra:
raw_link = payment_options.extra.get("link")
extra_link = str(raw_link)[:200] if raw_link else None
meta = [
payment_options.wallet_id,
payment_options.tag,
payment_options.subscription_request_id,
extra_link,
]
memo_limit = 493 - len(json.dumps(meta, separators=(",", ":")))
if memo_limit > 0 and payment_options.memo:
meta.append(payment_options.memo[:memo_limit])
else:
meta.append(None)
metadata = json.dumps(meta, separators=(",", ":"))
if len(metadata) > 500:
raise ValueError("Square subscription metadata is too long.")
return metadata
async def _get_square_subscription_id(
self, subscription_id: str, wallet_id: str
) -> str:
try:
from lnbits.core.crud.payments import get_payments
from lnbits.core.models import PaymentFilters
from lnbits.db import Filter, Filters
payments = await get_payments(
wallet_id=wallet_id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id and payment.fiat_provider == "square"
),
None,
)
if payment and payment.external_id:
return payment.external_id
payments = await get_payments(
wallet_id=wallet_id,
incoming=True,
filters=Filters(
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id
and payment.fiat_provider == "square"
and (payment.extra or {}).get("subscription_request_id")
== subscription_id
),
None,
)
if payment and payment.external_id:
return payment.external_id
except Exception as exc:
logger.warning(exc)
return subscription_id
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.square_api_endpoint),
str(settings.square_access_token),
str(settings.square_location_id),
str(settings.square_api_version),
]
)
+62 -1
View File
@@ -698,6 +698,35 @@ class PayPalFiatProvider(LNbitsSettings):
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class SquareFiatProvider(LNbitsSettings):
square_enabled: bool = Field(default=False)
square_api_endpoint: str = Field(default="https://connect.squareup.com")
square_access_token: str | None = Field(default=None)
square_location_id: str | None = Field(default=None)
square_api_version: str = Field(default="2026-01-22")
square_payment_success_url: str = Field(default="https://lnbits.com")
square_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
)
square_webhook_signature_key: str | None = Field(default=None)
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class RevolutFiatProvider(LNbitsSettings):
revolut_enabled: bool = Field(default=False)
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
revolut_api_secret_key: str | None = Field(default=None)
revolut_api_version: str = Field(default="2026-04-20")
revolut_payment_success_url: str = Field(default="https://lnbits.com")
revolut_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
)
revolut_webhook_signing_secret: str | None = Field(default=None)
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -735,7 +764,14 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider): class FiatProvidersSettings(
StripeFiatProvider,
PayPalFiatProvider,
SquareFiatProvider,
RevolutFiatProvider,
):
fiat_providers_admin_only: bool = Field(default=True)
def is_fiat_provider_enabled(self, provider: str | None) -> bool: def is_fiat_provider_enabled(self, provider: str | None) -> bool:
""" """
Checks if a specific fiat provider is enabled. Checks if a specific fiat provider is enabled.
@@ -746,12 +782,25 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
return self.stripe_enabled return self.stripe_enabled
if provider == "paypal": if provider == "paypal":
return self.paypal_enabled return self.paypal_enabled
if provider == "square":
return self.square_enabled
if provider == "revolut":
return self.revolut_enabled
return False return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]: def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
""" """
Returns a list of fiat payment methods allowed for the user. Returns a list of fiat payment methods allowed for the user.
""" """
if self.fiat_providers_admin_only:
if not self.is_admin_user(user_id):
return []
return [
provider
for provider in ["stripe", "paypal", "square", "revolut"]
if self.is_fiat_provider_enabled(provider)
]
allowed_providers = [] allowed_providers = []
if self.stripe_enabled and ( if self.stripe_enabled and (
not self.stripe_limits.allowed_users not self.stripe_limits.allowed_users
@@ -765,6 +814,18 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
): ):
allowed_providers.append("paypal") allowed_providers.append("paypal")
if self.square_enabled and (
not self.square_limits.allowed_users
or user_id in self.square_limits.allowed_users
):
allowed_providers.append("square")
if self.revolut_enabled and (
not self.revolut_limits.allowed_users
or user_id in self.revolut_limits.allowed_users
):
allowed_providers.append("revolut")
return allowed_providers return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None: def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -267,6 +267,15 @@ window.localisation.en = {
webhook_events_list: 'The following events must be supported by the webhook:', webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description: webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.', 'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
webhook_square_description:
'On the Square side configure a webhook pointing to this exact LNbits URL.',
square_webhook_url_hint:
'Must exactly match the Square notification URL. LNbits requires the /api/v1/callback/square path.',
access_token: 'Access Token',
location_id: 'Location ID',
square_location_id_hint:
'Square location ID to create payment links for. Use the endpoint to select sandbox or production.',
api_version: 'API Version',
payment_proof: 'Payment Proof', payment_proof: 'Payment Proof',
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
@@ -805,6 +814,8 @@ window.localisation.en = {
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.', webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description: webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.', 'On the PayPal side configure a webhook pointing to your LNbits server.',
square_webhook_signature_key_hint:
'Square webhook signature key used to verify incoming events.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful', 'The user will be redirected to this URL after the payment is successful',
@@ -5,10 +5,28 @@ window.app.component('lnbits-admin-fiat-providers', {
return { return {
formAddStripeUser: '', formAddStripeUser: '',
formAddPaypalUser: '', formAddPaypalUser: '',
formAddSquareUser: '',
formAddRevolutUser: '',
creatingRevolutWebhook: false,
hideInputToggle: true hideInputToggle: true
} }
}, },
computed: { computed: {
fiatProvidersAllUsers: {
get() {
return this.formData?.fiat_providers_admin_only === false
},
set(value) {
this.formData.fiat_providers_admin_only = !value
this.formData.touch = null
}
},
fiatProviderAccessLabel() {
return this.fiatProvidersAllUsers ? 'All users' : 'Admins only'
},
secretInputStyle() {
return this.hideInputToggle ? {'-webkit-text-security': 'disc'} : {}
},
stripeWebhookUrl() { stripeWebhookUrl() {
return ( return (
this.formData?.stripe_payment_webhook_url || this.formData?.stripe_payment_webhook_url ||
@@ -20,6 +38,12 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData?.paypal_payment_webhook_url || this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal') this.calculateWebhookUrl('paypal')
) )
},
revolutWebhookUrl() {
return (
this.formData?.revolut_payment_webhook_url ||
this.calculateWebhookUrl('revolut')
)
} }
}, },
watch: { watch: {
@@ -58,6 +82,8 @@ window.app.component('lnbits-admin-fiat-providers', {
syncWebhookUrls() { syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe') this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal') this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
}, },
maybeSetWebhookUrl(fieldName, provider) { maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) { if (!this.formData) {
@@ -77,6 +103,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 (
@@ -111,6 +178,40 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.paypal_limits.allowed_users = this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user) this.formData.paypal_limits.allowed_users.filter(u => u !== user)
}, },
addSquareAllowedUser() {
const addUser = this.formAddSquareUser || ''
if (
addUser.length &&
!this.formData.square_limits.allowed_users.includes(addUser)
) {
this.formData.square_limits.allowed_users = [
...this.formData.square_limits.allowed_users,
addUser
]
this.formAddSquareUser = ''
}
},
removeSquareAllowedUser(user) {
this.formData.square_limits.allowed_users =
this.formData.square_limits.allowed_users.filter(u => u !== user)
},
addRevolutAllowedUser() {
const addUser = this.formAddRevolutUser || ''
if (
addUser.length &&
!this.formData.revolut_limits.allowed_users.includes(addUser)
) {
this.formData.revolut_limits.allowed_users = [
...this.formData.revolut_limits.allowed_users,
addUser
]
this.formAddRevolutUser = ''
}
},
removeRevolutAllowedUser(user) {
this.formData.revolut_limits.allowed_users =
this.formData.revolut_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) { checkFiatProvider(providerName) {
LNbits.api LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`) .request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -124,6 +225,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
})
} }
} }
}) })
@@ -1,5 +1,5 @@
<template id="lnbits-admin-fiat-providers"> <template id="lnbits-admin-fiat-providers">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm row items-center q-gutter-sm">
<span v-text="$t('fiat_providers')"></span> <span v-text="$t('fiat_providers')"></span>
<q-btn <q-btn
round round
@@ -7,6 +7,18 @@
@click="hideInputToggle = !hideInputToggle" @click="hideInputToggle = !hideInputToggle"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'" :icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn> ></q-btn>
<q-toggle
dense
size="sm"
color="warning"
v-model="fiatProvidersAllUsers"
:label="fiatProviderAccessLabel"
>
<q-tooltip>
If enabled for all users, your users may pass a memo that suspends your
account with your fiat providers
</q-tooltip>
</q-toggle>
</h6> </h6>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@@ -46,7 +58,11 @@
<q-input <q-input
filled filled
class="q-mt-md" class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'" type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.stripe_api_secret_key" v-model="formData.stripe_api_secret_key"
:label="$t('secret_key')" :label="$t('secret_key')"
></q-input> ></q-input>
@@ -108,7 +124,11 @@
<q-input <q-input
filled filled
class="q-mt-md" class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'" type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.stripe_webhook_signing_secret" v-model="formData.stripe_webhook_signing_secret"
:label="$t('signing_secret')" :label="$t('signing_secret')"
:hint="$t('signing_secret_hint')" :hint="$t('signing_secret_hint')"
@@ -325,14 +345,22 @@
<q-input <q-input
filled filled
class="q-mt-md" class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'" type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.paypal_client_id" v-model="formData.paypal_client_id"
:label="$t('client_id')" :label="$t('client_id')"
></q-input> ></q-input>
<q-input <q-input
filled filled
class="q-mt-md" class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'" type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.paypal_client_secret" v-model="formData.paypal_client_secret"
:label="$t('secret_key')" :label="$t('secret_key')"
></q-input> ></q-input>
@@ -394,7 +422,11 @@
<q-input <q-input
filled filled
class="q-mt-md" class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'" type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.paypal_webhook_id" v-model="formData.paypal_webhook_id"
:label="$t('webhook_id')" :label="$t('webhook_id')"
:hint="$t('webhook_id_hint')" :hint="$t('webhook_id_hint')"
@@ -587,12 +619,556 @@
<q-item-section> Square </q-item-section> <q-item-section> Square </q-item-section>
<q-item-section side> <q-item-section side>
<div class="row items-center">Disabled</div> <div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.square_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section> </q-item-section>
</template> </template>
<q-card> <q-card class="q-pb-xl">
<q-card-section> Coming Soon </q-card-section> <q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.square_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.square_access_token"
:label="$t('access_token')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.square_location_id"
:label="$t('location_id')"
:hint="$t('square_location_id_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.square_api_version"
:label="$t('api_version')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.square_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('square')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_square_description')"></span>
</q-card-section>
<q-card-section>
<div class="row items-center q-gutter-sm q-mt-md">
<div class="col">
<q-input
filled
type="text"
v-model="formData.square_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('square_webhook_url_hint')"
></q-input>
</div>
<div class="col-auto">
<q-btn
outline
color="grey"
icon="content_copy"
@click="
copyWebhookUrl(formData.square_payment_webhook_url)
"
:aria-label="$t('copy_webhook_url')"
>
<q-tooltip>
<span v-text="$t('copy_webhook_url')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.square_webhook_signature_key"
:label="$t('signing_secret')"
:hint="$t('square_webhook_signature_key_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>payment.updated</code></li>
<li><code>invoice.payment_made</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_fee_percent"
@update:model-value="formData.touch = null"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_max_fee_sats"
@update:model-value="formData.touch = null"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.square_limits.service_fee_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_min_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_max_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.square_limits.service_faucet_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="
$t('faucest_wallet_desc_1', {
provider: 'square'
})
"
></span>
</li>
<li>
<span
v-text="
$t('faucest_wallet_desc_2', {
provider: 'square'
})
"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="
$t('faucest_wallet_desc_4', {
provider: 'square'
})
"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddSquareUser"
@keydown.enter="addSquareAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="
$t('allowed_users_hint_feature', {
feature: 'Square'
})
"
>
<q-btn
@click="addSquareAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.square_limits.allowed_users"
@update:model-value="formData.touch = null"
:key="user"
removable
@remove="removeSquareAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar color="deep-orange-7" text-color="white">R</q-avatar>
</q-item-section>
<q-item-section> Revolut </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.revolut_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.revolut_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
:input-style="secretInputStyle"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
v-model="formData.revolut_api_secret_key"
label="API secret key"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.revolut_api_version"
:label="$t('api_version')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.revolut_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('revolut')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
Configure a Revolut Merchant webhook that points to your LNbits
server. LNbits will create it through the Revolut API and
subscribe to <code>ORDER_AUTHORISED</code>,
<code>ORDER_COMPLETED</code>, and
<code>SUBSCRIPTION_INITIATED</code>.
</q-card-section>
<q-card-section>
<div class="row items-center q-gutter-sm q-mt-md">
<div class="col">
<q-input
filled
type="text"
disable
:model-value="revolutWebhookUrl"
:label="$t('webhook_url')"
readonly
></q-input>
</div>
<div class="col-auto">
<q-btn
outline
color="grey"
icon="content_copy"
@click="copyWebhookUrl(revolutWebhookUrl)"
:aria-label="$t('copy_webhook_url')"
>
<q-tooltip>
<span v-text="$t('copy_webhook_url')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
<div class="row items-center q-gutter-sm q-mt-md">
<q-btn
type="button"
color="primary"
icon="add_link"
label="Create webhook"
: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-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_fee_percent"
@update:model-value="formData.touch = null"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_max_fee_sats"
@update:model-value="formData.touch = null"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.revolut_limits.service_fee_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_min_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_max_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.revolut_limits.service_faucet_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddRevolutUser"
@keydown.enter="addRevolutAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="
$t('allowed_users_hint_feature', {
feature: 'Revolut'
})
"
>
<q-btn
@click="addRevolutAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.revolut_limits.allowed_users"
@update:model-value="formData.touch = null"
:key="user"
removable
@remove="removeRevolutAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
</q-list> </q-list>
@@ -640,9 +1216,22 @@
> >
</div> </div>
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px"> <div class="text-bold" style="min-width: 140px">Square</div>
Square (coming soon) <q-chip dense color="positive" text-color="white" icon="check"
</div> >Checkout</q-chip
>
<q-chip dense color="warning" text-color="black" icon="schedule"
>Subscriptions coming soon</q-chip
>
<q-chip dense color="negative" text-color="white" icon="close"
>Tap-to-pay</q-chip
>
<q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Square-supported countries</q-chip
>
</div>
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">Revolut</div>
<q-chip dense color="positive" text-color="white" icon="check" <q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip >Checkout</q-chip
> >
@@ -653,7 +1242,7 @@
>Tap-to-pay</q-chip >Tap-to-pay</q-chip
> >
<q-chip dense color="grey-9" text-color="white" icon="public" <q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Global</q-chip >Regions: Revolut-supported countries</q-chip
> >
</div> </div>
</div> </div>
+38
View File
@@ -394,6 +394,44 @@
<span v-text="$t('pay_with', {provider: 'PayPal'})"></span> <span v-text="$t('pay_with', {provider: 'PayPal'})"></span>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('square')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('square')"
:active="receive.fiatProvider === 'square'"
@click="receive.fiatProvider = 'square'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar>
<q-img src="/static/images/square_logo.png"></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Square'})"></span>
</q-item-section>
</q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('revolut')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('revolut')"
:active="receive.fiatProvider === 'revolut'"
@click="receive.fiatProvider = 'revolut'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar color="deep-orange-7" text-color="white">R</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Revolut'})"></span>
</q-item-section>
</q-item>
</q-list> </q-list>
</div> </div>
+460 -2
View File
@@ -4,13 +4,18 @@ from uuid import uuid4
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice from lnbits.core.models import Account, CreateInvoice, Payment
from lnbits.core.services.payments import create_wallet_invoice from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import ( from lnbits.core.views.callback_api import (
handle_paypal_event, handle_paypal_event,
handle_revolut_event,
handle_square_event,
handle_stripe_event, handle_stripe_event,
) )
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import Settings
@pytest.mark.anyio @pytest.mark.anyio
@@ -23,7 +28,15 @@ async def test_callback_api_generic_webhook_handler_routes_providers(
paypal_mock = mocker.patch( paypal_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock() "lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock()
) )
square_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_square_event", mocker.AsyncMock()
)
revolut_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_revolut_event", mocker.AsyncMock()
)
mocker.patch("lnbits.core.views.callback_api.check_stripe_signature") mocker.patch("lnbits.core.views.callback_api.check_stripe_signature")
mocker.patch("lnbits.core.views.callback_api.check_square_signature")
mocker.patch("lnbits.core.views.callback_api.check_revolut_signature")
mocker.patch( mocker.patch(
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock() "lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
) )
@@ -45,6 +58,27 @@ async def test_callback_api_generic_webhook_handler_routes_providers(
assert paypal.json()["success"] is True assert paypal.json()["success"] is True
paypal_mock.assert_awaited_once() paypal_mock.assert_awaited_once()
square = await http_client.post(
"/api/v1/callback/square",
headers={"x-square-hmacsha256-signature": "sig"},
json={"event_id": "evt_3", "type": "payment.updated"},
)
assert square.status_code == 200
assert square.json()["success"] is True
square_mock.assert_awaited_once()
revolut = await http_client.post(
"/api/v1/callback/revolut",
headers={
"Revolut-Signature": "sig",
"Revolut-Request-Timestamp": "1700000000",
},
json={"event": "ORDER_COMPLETED", "order_id": "order_1"},
)
assert revolut.status_code == 200
assert revolut.json()["success"] is True
revolut_mock.assert_awaited_once()
unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"}) unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"})
assert unknown.status_code == 200 assert unknown.status_code == 200
assert unknown.json()["success"] is False assert unknown.json()["success"] is False
@@ -95,7 +129,258 @@ async def test_callback_api_handles_paid_events_with_real_payments(mocker):
@pytest.mark.anyio @pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(mocker): async def test_callback_api_handles_square_paid_events(mocker):
payment = mocker.Mock()
get_payment = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_square_event(
{
"event_id": "evt_square",
"type": "payment.updated",
"data": {
"object": {
"payment": {
"id": "payment_1",
"order_id": "order_1",
"status": "COMPLETED",
}
}
},
}
)
get_payment.assert_awaited_once_with("fiat_square_order_order_1")
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_revolut_paid_events(mocker):
payment = mocker.Mock()
get_payment = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "ORDER_COMPLETED",
"order_id": "order_1",
}
)
get_payment.assert_awaited_once_with("fiat_revolut_order_order_1")
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_revolut_subscription_event(
mocker, settings: Settings
):
wallet_id = "wallet_1"
payment = mocker.Mock()
payment.extra = {}
payment.msat = 925_000
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet()
mocker.patch.object(
revolut_provider,
"get_subscription",
return_value={
"id": "SUBSCRIPTION_1",
"current_cycle_id": "CYCLE_1",
"external_reference": json.dumps(
{
"wallet_id": wallet_id,
"tag": "members",
"subscription_request_id": "request_1",
"extra": {"link": "link-1", "customer_id": "customer_1"},
"memo": "Revolut Members",
}
),
},
)
mocker.patch.object(
revolut_provider,
"get_subscription_cycle",
return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"},
)
mocker.patch.object(
revolut_provider,
"get_order",
return_value={
"id": "ORDER_SUB_1",
"amount": 925,
"currency": "USD",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_1",
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=revolut_provider),
)
mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None]),
)
create_wallet_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_wallet_invoice",
mocker.AsyncMock(return_value=payment),
)
mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment", mocker.AsyncMock()
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "SUBSCRIPTION_INITIATED",
"subscription_id": "SUBSCRIPTION_1",
}
)
assert create_wallet_invoice_mock.await_count == 1
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args
assert called_wallet_id == "wallet_1"
assert invoice.amount == 9.25
assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.internal is True
assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1"
assert payment.fiat_provider == "revolut"
assert payment.fee == -2
assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1"
assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1"
update_payment_mock.assert_awaited_once_with(
payment, "fiat_revolut_order_ORDER_SUB_1"
)
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_revolut_subscription_order_event(
mocker, settings: Settings
):
wallet_id = "wallet_1"
payment = mocker.Mock()
payment.extra = {}
payment.msat = 925_000
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet()
subscription = {
"id": "SUBSCRIPTION_1",
"state": "active",
"current_cycle_id": "CYCLE_1",
"external_reference": json.dumps(
{
"wallet_id": wallet_id,
"tag": "members",
"subscription_request_id": "request_1",
"extra": {"link": "link-1"},
"memo": "Revolut Members",
}
),
}
order = {
"id": "ORDER_SUB_1",
"type": "payment",
"state": "completed",
"amount": 925,
"currency": "USD",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_1",
"channel_data": {
"subscription_id": "SUBSCRIPTION_1",
"subscription_cycle_id": "CYCLE_1",
},
}
get_order_mock = mocker.patch.object(
revolut_provider, "get_order", side_effect=[order, order]
)
get_subscription_mock = mocker.patch.object(
revolut_provider,
"get_subscription",
return_value=subscription,
)
mocker.patch.object(
revolut_provider,
"get_subscription_cycle",
return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=revolut_provider),
)
get_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None, None]),
)
create_wallet_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_wallet_invoice",
mocker.AsyncMock(return_value=payment),
)
mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment", mocker.AsyncMock()
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "ORDER_COMPLETED",
"order_id": "ORDER_SUB_1",
}
)
assert get_payment_mock.await_count == 2
get_payment_mock.assert_any_await("fiat_revolut_order_ORDER_SUB_1")
assert get_order_mock.await_count == 2
assert [call.args for call in get_subscription_mock.await_args_list] == [
("SUBSCRIPTION_1",),
("SUBSCRIPTION_1",),
]
assert create_wallet_invoice_mock.await_count == 1
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args
assert called_wallet_id == "wallet_1"
assert invoice.amount == 9.25
assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.internal is True
assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1"
assert payment.fiat_provider == "revolut"
assert payment.fee == -2
assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1"
assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1"
update_payment_mock.assert_awaited_once_with(
payment, "fiat_revolut_order_ORDER_SUB_1"
)
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(
mocker, settings: Settings
):
user = await create_user_account( user = await create_user_account(
Account( Account(
id=uuid4().hex, id=uuid4().hex,
@@ -163,6 +448,99 @@ async def test_callback_api_handles_subscription_flows_and_validation(mocker):
) )
assert create_fiat_invoice_mock.await_count == 2 assert create_fiat_invoice_mock.await_count == 2
await handle_square_event(
{
"event_id": "evt_square_subscription",
"type": "payment.updated",
"data": {
"object": {
"payment": {
"id": "PAYMENT_SUB_1",
"order_id": "ORDER_SUB_1",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
"note": json.dumps(
[
wallet.id,
"members",
"subscription_square_1",
"link-1",
"Square Members",
]
),
}
}
},
}
)
assert create_fiat_invoice_mock.await_count == 3
square_call = create_fiat_invoice_mock.await_args.kwargs
assert square_call["wallet_id"] == wallet.id
square_invoice = square_call["invoice_data"]
assert square_invoice.fiat_provider == "square"
assert square_invoice.amount == 9.25
assert square_invoice.memo == "Square Members"
assert square_invoice.extra["fiat_method"] == "subscription"
assert square_invoice.extra["tag"] == "members"
assert (
square_invoice.extra["subscription"]["checking_id"] == "payment_PAYMENT_SUB_1"
)
payment.extra = {
"subscription_request_id": "subscription_square_1",
"tag": "members",
"link": "link-1",
}
payment.external_id = "SUBSCRIPTION_1"
payment.memo = "Square Members"
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
settings.square_api_version = "2026-01-22"
square_provider = SquareWallet()
mocker.patch.object(
square_provider,
"get_payment_for_order",
return_value={
"id": "PAYMENT_SUB_2",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=square_provider),
)
mocker.patch(
"lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=[payment]),
)
await handle_square_event(
{
"event_id": "evt_square_invoice",
"type": "invoice.payment_made",
"data": {
"object": {
"invoice": {
"order_id": "ORDER_SUB_2",
"subscription_id": "SUBSCRIPTION_1",
"public_url": "https://square.example/invoice",
}
}
},
}
)
assert create_fiat_invoice_mock.await_count == 4
square_invoice_call = create_fiat_invoice_mock.await_args.kwargs
square_invoice = square_invoice_call["invoice_data"]
assert square_invoice.external_id == "SUBSCRIPTION_1"
assert "square_subscription_id" not in square_invoice.extra
assert (
square_invoice.extra["subscription"]["payment_request"]
== "https://square.example/invoice"
)
with pytest.raises( with pytest.raises(
ValueError, match="PayPal subscription event missing custom metadata." ValueError, match="PayPal subscription event missing custom metadata."
): ):
@@ -173,3 +551,83 @@ async def test_callback_api_handles_subscription_flows_and_validation(mocker):
"resource": {"amount": {"currency": "USD", "total": "5.00"}}, "resource": {"amount": {"currency": "USD", "total": "5.00"}},
} }
) )
@pytest.mark.anyio
async def test_square_invoice_payment_updates_existing_subscription_external_id(
settings: Settings, mocker
):
payment = Payment(
checking_id="fiat_square_payment_PAYMENT_SUB_1",
payment_hash="hash_square_subscription",
wallet_id="wallet_1",
amount=925000,
fee=0,
bolt11="lnbc1square",
fiat_provider="square",
extra={
"subscription_request_id": "subscription_square_1",
"tag": "members",
"link": "link-1",
},
memo="Square Members",
)
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
settings.square_api_version = "2026-01-22"
square_provider = SquareWallet()
mocker.patch.object(
square_provider,
"get_payment_for_order",
return_value={
"id": "PAYMENT_SUB_1",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=square_provider),
)
get_standalone_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment",
mocker.AsyncMock(),
)
get_payments_mock = mocker.patch(
"lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=[]),
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_square_event(
{
"event_id": "evt_square_invoice",
"type": "invoice.payment_made",
"data": {
"object": {
"invoice": {
"order_id": "ORDER_SUB_1",
"subscription_id": "SUBSCRIPTION_1",
}
}
},
}
)
get_standalone_payment_mock.assert_awaited_with("fiat_square_payment_PAYMENT_SUB_1")
assert payment.external_id == "SUBSCRIPTION_1"
update_payment_mock.assert_awaited_once_with(payment)
fiat_status_mock.assert_awaited_once_with(payment)
get_payments_mock.assert_not_awaited()
create_fiat_invoice_mock.assert_not_awaited()
+81
View File
@@ -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()
+58 -3
View File
@@ -4,9 +4,10 @@ from uuid import uuid4
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentState from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo from lnbits.core.models.wallets import KeyType, WalletTypeInfo
@@ -21,7 +22,7 @@ from lnbits.core.views.payment_api import (
api_payments_settle, api_payments_settle,
api_payments_wallets_stats, api_payments_wallets_stats,
) )
from lnbits.db import Filters from lnbits.db import Filter, Filters
from lnbits.wallets.base import InvoiceResponse from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = ( ZERO_AMOUNT_INVOICE = (
@@ -91,6 +92,60 @@ async def test_payment_api_stats_and_all_paginated(admin_user):
assert second_wallet.id in wallet_ids assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_external_id_is_stored_and_validated():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
first_payment = await create_wallet_invoice(
wallet.id,
CreateInvoice(
out=False,
amount=21,
memo="external reference",
external_id="provider_payment_123",
),
)
second_payment = await create_wallet_invoice(
wallet.id,
CreateInvoice(
out=False,
amount=22,
memo="external reference newest",
external_id="provider_payment_123",
),
)
assert first_payment.external_id == "provider_payment_123"
assert second_payment.external_id == "provider_payment_123"
stored_payments = await get_payments(
wallet_id=wallet.id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", ["provider_payment_123"], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
assert [payment.checking_id for payment in stored_payments] == [
second_payment.checking_id,
first_payment.checking_id,
]
with pytest.raises(ValidationError, match="Invalid external id"):
CreateInvoice(out=False, amount=21, external_id="provider payment 123")
@pytest.mark.anyio @pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker): async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account( user = await create_user_account(
File diff suppressed because it is too large Load Diff