Compare commits

..
Author SHA1 Message Date
Vlad Stan 4744ef9bba Merge branch 'more_unit_tests' of https://github.com/lnbits/lnbits into more_unit_tests 2026-03-30 16:00:12 +03:00
Vlad Stan fdf061c08d chore: make regtest wait 2026-03-30 15:32:42 +03:00
Vlad Stan d348234cad fix: aaaaa 2026-03-30 15:32:42 +03:00
Vlad Stan fc5261132e fix: regtests 2026-03-30 15:32:42 +03:00
Vlad Stan 4e18c423de chore: check log 2026-03-30 15:32:42 +03:00
Vlad Stan 37e5601207 fix: try clean-up 2026-03-30 15:32:42 +03:00
Vlad Stan 231f6fc01c fix: is boltz wallet 2026-03-30 15:32:42 +03:00
Vlad Stan 811957f5e1 chore: logs 2026-03-30 15:32:42 +03:00
Vlad Stan c63a53cb14 chore: some logs 2026-03-30 15:32:42 +03:00
Vlad Stan 78b8f7f16e fix: hope for regtest to work 2026-03-30 15:32:42 +03:00
Vlad Stan 4642878c05 fix: test 2026-03-30 15:32:42 +03:00
Vlad Stan d7c850ccbb fix: test funding source 2026-03-30 15:32:42 +03:00
Vlad Stan 4fbb4b5eef chore: fix lint 2026-03-30 15:32:42 +03:00
Vlad Stan db2829a5f6 chore: postpone test 2026-03-30 15:31:59 +03:00
Vlad Stan 13ce1ac172 chore: fix lint 2026-03-30 15:08:42 +03:00
Vlad Stan 512697ae53 test: more tests 2026-03-30 14:58:04 +03:00
Vlad Stan dfa89ca063 test: some tests 2026-03-30 13:18:36 +03:00
Vlad Stan 182694d2e5 fix: bad config 2026-03-30 13:12:47 +03:00
Vlad Stan 2f10c57a10 fix: timeout 2026-03-30 13:12:46 +03:00
Vlad Stan d685824596 fix: test funding source 2026-03-30 13:12:46 +03:00
Vlad Stan e816eb2dac fix: date 2026-03-30 13:12:46 +03:00
Vlad Stan d3c099350a chore: fix lint 2026-03-30 13:12:46 +03:00
Vlad Stan 691ac969dc chore: postpone test 2026-03-30 13:12:46 +03:00
Vlad Stan 85e8ac6057 refactor: extract helpers, move private functions to the bottom 2026-03-30 13:12:46 +03:00
Vlad Stan 99da300f6d chore: code lint 2026-03-30 13:12:46 +03:00
Vlad Stan cd413d85ed fix: tests 2026-03-30 13:12:46 +03:00
Vlad Stan 9245e2c268 fix: lint 2026-03-30 13:12:46 +03:00
Vlad Stan 0b5c8cdee2 test: more api tests 2026-03-30 13:12:46 +03:00
Vlad Stan c94cef07c2 chore: fix lint 2026-03-30 13:12:46 +03:00
Vlad Stan 22b4ab779f test: more tests 2026-03-30 13:12:46 +03:00
Vlad Stan 42061d0cc8 test: some tests 2026-03-30 13:12:46 +03:00
Vlad Stan d0ea42fd70 chore: make regtest wait 2026-03-26 12:28:12 +02:00
Vlad Stan cd58a0dc9b fix: aaaaa 2026-03-26 12:12:13 +02:00
Vlad Stan cac4e309f4 fix: regtests 2026-03-26 11:57:25 +02:00
Vlad Stan 632cd257d7 chore: check log 2026-03-26 11:50:30 +02:00
Vlad Stan a11274593c fix: try clean-up 2026-03-26 11:49:57 +02:00
Vlad Stan ae92965b69 fix: is boltz wallet 2026-03-26 11:45:16 +02:00
Vlad Stan 3e1f75c4cc chore: logs 2026-03-26 11:32:02 +02:00
Vlad Stan d668747ca2 chore: some logs 2026-03-26 11:24:18 +02:00
Vlad Stan dedfcd4818 fix: hope for regtest to work 2026-03-25 11:37:21 +02:00
Vlad Stan f66f2fe6c2 fix: test 2026-03-25 11:24:25 +02:00
Vlad Stan f3ca37e846 Merge branch 'more_unit_tests' of https://github.com/lnbits/lnbits into more_unit_tests 2026-03-25 10:54:23 +02:00
Vlad Stan d45709a80f fix: bad config 2026-03-25 10:53:25 +02:00
Vlad Stan e4b6dc84b5 fix: timeout 2026-03-25 10:49:52 +02:00
Vlad Stan 67d1b4be3e fix: test funding source 2026-03-25 10:49:52 +02:00
Vlad Stan bd514f1a46 fix: date 2026-03-25 10:49:52 +02:00
Vlad Stan 03b302da69 chore: fix lint 2026-03-25 10:49:52 +02:00
Vlad Stan b373e35915 chore: postpone test 2026-03-25 10:49:52 +02:00
Vlad Stan 0fd116e7ee refactor: extract helpers, move private functions to the bottom 2026-03-25 10:49:52 +02:00
Vlad Stan 2c84f9781d chore: code lint 2026-03-25 10:49:52 +02:00
Vlad Stan 9fcf3121ac fix: tests 2026-03-25 10:49:52 +02:00
Vlad Stan 1df9fa831d fix: lint 2026-03-25 10:49:52 +02:00
Vlad Stan 42039070b9 test: more api tests 2026-03-25 10:49:52 +02:00
Vlad Stan 2f9146fff3 chore: fix lint 2026-03-25 10:49:52 +02:00
Vlad Stan 5738795c8c test: more tests 2026-03-25 10:49:52 +02:00
Vlad Stan 3c77acf266 test: some tests 2026-03-25 10:49:52 +02:00
Vlad Stan a4f2f55279 fix: timeout 2026-03-23 14:01:55 +02:00
Vlad Stan 2ab7c17ceb fix: test funding source 2026-03-23 12:33:57 +02:00
Vlad Stan 7a2c509cd3 fix: date 2026-03-23 12:32:39 +02:00
Vlad Stan 8a51ea53c8 chore: fix lint 2026-03-23 12:32:39 +02:00
Vlad Stan 19d3e9e64e chore: postpone test 2026-03-23 12:32:39 +02:00
Vlad Stan 9f9f14ead0 refactor: extract helpers, move private functions to the bottom 2026-03-23 12:32:39 +02:00
Vlad Stan 0a093d82ae chore: code lint 2026-03-23 12:32:39 +02:00
Vlad Stan 17fd459fa7 fix: tests 2026-03-23 12:32:39 +02:00
Vlad Stan fe9aa4d8c9 fix: lint 2026-03-23 12:32:39 +02:00
Vlad Stan 1af3b62739 test: more api tests 2026-03-23 12:32:39 +02:00
Vlad Stan e9cbc3492f chore: fix lint 2026-03-23 12:32:39 +02:00
Vlad Stan ca2f78d25b test: more tests 2026-03-23 12:32:39 +02:00
Vlad Stan 9d9ab48345 test: some tests 2026-03-23 12:32:39 +02:00
76 changed files with 362 additions and 4796 deletions
+1 -11
View File
@@ -7,20 +7,14 @@
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# If set the user is required to enter this token on the /first_install page
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
@@ -30,10 +24,6 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings ===
-3
View File
@@ -69,10 +69,7 @@ jobs:
--onefile \
--name lnbits \
--hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
make: openapi
regtest:
needs: [ lint ]
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi ]
uses: ./.github/workflows/regtest.yml
strategy:
matrix:
-1
View File
@@ -6,7 +6,6 @@ __pycache__
*$py.class
.mypy_cache
.vscode
.codex
*-lock.json
.python-version
-60
View File
@@ -1,60 +0,0 @@
# AGENTS.md - AI Coding Agent Guide for LNbits
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
## Core Behavior
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
- Prefer the simplest implementation that solves the request.
- Make surgical changes. Every changed line should trace back to the task.
- Do not refactor, reformat, rename, or clean adjacent code unless required.
- Remove only dead code or imports created by your own changes.
- Define success criteria for non-trivial work and verify them before reporting done.
## LNbits Architecture
- Keep core lean. Prefer/assess extensions for non-core features.
- Preserve compatibility with existing extensions and wallet backends.
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
- Use existing CRUD, services, settings, and migration patterns.
- Do not edit generated files, bundled vendor files, or unrelated extension code.
## Security-Sensitive Areas
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
## Commands and Verification
Read `Makefile` before running project commands.
Use Makefile targets instead of hand-written commands when available:
- `make check` for full checks.
- `make test-unit` for unit tests.
- `make test-api` for API tests.
- `make test-wallets` for wallet tests.
- `make checkbundle` when bundled frontend assets may be affected.
- `make format` only when formatting is intended.
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
## Dependencies
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
## Maintenance
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
## Reporting
When finished, report:
- Summary of what changed.
- Files touched.
- Makefile targets or checks run.
- Anything not verified and why.
+11 -11
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
<img alt="lnbits_head" src="docs/assets/header.jpg" />
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img alt="lnbits_api" src="docs/assets/api.jpg" />
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
## LNbits merchant tools
## LNbits across all your devices
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table
+1 -1
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
+1
View File
@@ -14,6 +14,7 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets
-4
View File
@@ -65,7 +65,6 @@ from .middleware import (
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -197,9 +196,6 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app
-4
View File
@@ -12,7 +12,6 @@ from .extensions import (
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
@@ -59,7 +58,6 @@ from .users import (
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_accounts_count,
get_user,
get_user_access_control_lists,
get_user_from_account,
@@ -119,13 +117,11 @@ __all__ = [
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_accounts_count",
"get_admin_settings",
"get_db_version",
"get_db_versions",
"get_installed_extension",
"get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
-7
View File
@@ -90,13 +90,6 @@ async def get_installed_extensions(
return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
-1
View File
@@ -292,7 +292,6 @@ async def create_payment(
tag=extra.get("tag", None),
extra=extra,
labels=data.labels or [],
external_id=data.external_id,
)
await (conn or db).insert("apipayments", payment)
-7
View File
@@ -37,13 +37,6 @@ async def create_account(
return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account)
-13
View File
@@ -802,16 +802,3 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status.
"""
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,7 +13,6 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import (
FiatPaymentStatus,
)
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import (
PaymentStatus,
@@ -54,11 +53,6 @@ class CreatePayment(BaseModel):
webhook: str | None = None
fee: int = 0
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):
@@ -83,11 +77,6 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
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):
super().__init__(**data)
@@ -162,7 +151,6 @@ class PaymentFilters(FilterModel):
"status",
"time",
"labels",
"external_id",
]
__sort_fields__ = [
@@ -173,13 +161,11 @@ class PaymentFilters(FilterModel):
"memo",
"time",
"tag",
"external_id",
]
status: str | None
tag: str | None
checking_id: str | None
external_id: str | None
amount: int
fee: int
memo: str | None
@@ -263,7 +249,6 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash")
def check_hex(cls, v):
@@ -278,10 +263,6 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported")
return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel):
incoming: int = 0
@@ -320,12 +301,3 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel):
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
-12
View File
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
delete_installed_extension,
get_db_version,
get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
@@ -39,8 +38,6 @@ async def install_extension(
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download:
await ext_info.download_archive()
@@ -66,15 +63,6 @@ async def install_extension(
return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
-86
View File
@@ -1,9 +1,7 @@
import hashlib
import hmac
import json
import math
import time
from base64 import b64encode
import httpx
from loguru import logger
@@ -171,90 +169,6 @@ async def verify_paypal_webhook(headers, payload: bytes):
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.")
timestamp = int(timestamp_header)
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if not math.isfinite(timestamp_seconds):
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.")
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
candidates = [
b"v1." + timestamp_header.encode() + b"." + payload,
payload,
f"{timestamp_header}.{payload.decode()}".encode(),
timestamp_header.encode() + b"." + payload,
]
signatures = []
for candidate in candidates:
digest = hmac.new(
key=secret.encode(), msg=candidate, digestmod=hashlib.sha256
).digest()
signatures.extend(
[digest.hex(), f"v1={digest.hex()}", b64encode(digest).decode()]
)
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any(
hmac.compare_digest(expected, provided)
for expected in signatures
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
raise ValueError("Revolut signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
-5
View File
@@ -64,7 +64,6 @@ async def pay_invoice(
description: str = "",
tag: str = "",
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
@@ -98,7 +97,6 @@ async def pay_invoice(
memo=description or invoice.description or "",
extra=extra,
labels=labels,
external_id=external_id,
)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -219,7 +217,6 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
internal=data.internal,
payment_hash=data.payment_hash,
labels=data.labels,
external_id=data.external_id,
conn=conn,
)
@@ -261,7 +258,6 @@ async def create_invoice(
internal: bool | None = False,
payment_hash: str | None = None,
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@@ -346,7 +342,6 @@ async def create_invoice(
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
external_id=external_id,
)
payment = await create_payment(
-12
View File
@@ -23,7 +23,6 @@ from ..crud import (
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_accounts_count,
get_super_settings,
get_user_extensions,
get_user_from_account,
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None,
) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account:
account.validate_fields()
if account.username and await get_account_by_username(
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account:
account.validate_fields()
-350
View File
@@ -4,26 +4,17 @@ from fastapi import APIRouter, Request
from loguru import logger
from lnbits.core.crud.payments import (
get_payments,
get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentFilters
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -59,41 +50,6 @@ async def api_generic_webhook_handler(
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(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
@@ -324,309 +280,3 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
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}'.")
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:
logger.warning("Revolut subscription event missing subscription_id.")
return
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
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_fiat_invoice(
wallet_id=reference.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount_minor / 100,
memo=reference.memo or "",
extra=extra,
fiat_provider="revolut",
external_id=subscription_id,
),
)
await check_fiat_status(lnbits_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()
+2 -6
View File
@@ -98,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, (AssertionError, ValueError))
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException(
status_code=(
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=detail,
) from exc
+1 -67
View File
@@ -2,35 +2,17 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
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.wallets import WalletTypeInfo
from lnbits.core.services import update_cached_settings
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin, require_admin_key
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
from lnbits.fiat import StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
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(
"/check/{provider}",
status_code=HTTPStatus.OK,
@@ -40,54 +22,6 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
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(
"/{provider}/subscription",
status_code=HTTPStatus.OK,
-1
View File
@@ -263,7 +263,6 @@ async def api_payments_create(
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
external_id=invoice_data.external_id,
)
return payment
+2 -4
View File
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' format for the database URL."
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES
@@ -560,9 +560,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str:
stmt = ""
if self.limit == 0:
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
self.limit = self.limit or 10
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
-6
View File
@@ -9,8 +9,6 @@ from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .paypal import PayPalWallet
from .revolut import RevolutWallet
from .square import SquareWallet
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
@@ -19,8 +17,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
paypal = "PayPalWallet"
square = "SquareWallet"
revolut = "RevolutWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -56,7 +52,5 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"PayPalWallet",
"RevolutWallet",
"SquareWallet",
"StripeWallet",
]
-487
View File
@@ -1,487 +0,0 @@
import asyncio
import ipaddress
import json
from collections.abc import AsyncGenerator
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",
]
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=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 opts is None:
return FiatInvoiceResponse(
ok=False, error_message="Invalid Revolut options"
)
amount_minor = int(amount * 100)
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)
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 {}
customer_id = extra.get("customer_id")
if not customer_id:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions require extra.customer_id.",
)
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,
"customer_id": customer_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:
r = await self.client.post(
"/api/subscriptions", json=payload, headers=headers
)
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=revolut_subscription_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")
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}")
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}")
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}"
)
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=15)
response.raise_for_status()
return response.json()
@classmethod
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
response = await client.get("/api/webhooks", timeout=15)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
for field in ["webhooks", "data", "items"]:
if isinstance(data.get(field), list):
return data[field]
return []
@classmethod
async def _get_existing_webhook(
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
) -> dict[str, Any] | None:
for webhook in webhooks:
if cls._normalize_webhook_url(webhook.get("url")) != (
cls._normalize_webhook_url(url)
):
continue
webhook_id = webhook.get("id")
if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret")
):
response = await client.get(f"/api/webhooks/{webhook_id}", timeout=15)
response.raise_for_status()
webhook = response.json()
events = set(webhook.get("events") or [])
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
if missing_events:
raise ValueError(
"A Revolut webhook already exists for this URL, but it is "
f"missing required events: {', '.join(sorted(missing_events))}."
)
if not webhook.get("signing_secret"):
raise ValueError(
"A Revolut webhook already exists for this URL, but Revolut "
"did not return a signing secret."
)
return webhook
return None
@classmethod
def _normalize_webhook_url(cls, url: str | None) -> str:
return (url or "").strip().rstrip("/")
@classmethod
def _validate_webhook_url(cls, url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if parsed.scheme not in ["http", "https"] or not hostname:
raise ValueError("Revolut webhook URL must be a clearnet URL.")
host = hostname.lower()
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
if host.endswith(".local") or host.endswith(".onion"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("state") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELLED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
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),
]
)
-616
View File
@@ -1,616 +0,0 @@
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:
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),
]
)
-14
View File
@@ -7,7 +7,6 @@ from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger
from pyinstrument import Profiler
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
@@ -247,16 +246,3 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
def add_profiler_middleware(app: FastAPI):
@app.middleware("http")
async def profile_middleware(request: Request, call_next):
profiling = request.query_params.get("profiler", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
_ = await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
return await call_next(request)
+4 -59
View File
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png"
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -324,8 +324,7 @@ class AssetSettings(LNbitsSettings):
"heif",
"heics",
"text/plain",
"text/json",
"text/xml",
"text/json" "text/xml",
"application/json",
"application/pdf",
]
@@ -589,8 +588,6 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings):
@@ -703,35 +700,6 @@ class PayPalFiatProvider(LNbitsSettings):
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):
lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -769,12 +737,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(
StripeFiatProvider,
PayPalFiatProvider,
SquareFiatProvider,
RevolutFiatProvider,
):
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
@@ -785,10 +748,6 @@ class FiatProvidersSettings(
return self.stripe_enabled
if provider == "paypal":
return self.paypal_enabled
if provider == "square":
return self.square_enabled
if provider == "revolut":
return self.revolut_enabled
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -808,18 +767,6 @@ class FiatProvidersSettings(
):
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
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -1059,8 +1006,8 @@ class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False)
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
profiler: bool = Field(default=False)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0)
@@ -1079,8 +1026,6 @@ class EnvSettings(LNbitsSettings):
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
@property
def has_default_extension_path(self) -> bool:
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
-13
View File
@@ -267,15 +267,6 @@ window.localisation.en = {
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'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',
update: 'Update',
update_available: 'Update {version} available!',
@@ -284,8 +275,6 @@ window.localisation.en = {
requires_server_restart:
'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
@@ -814,8 +803,6 @@ window.localisation.en = {
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'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_hint:
'The user will be redirected to this URL after the payment is successful',
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -5,9 +5,6 @@ window.app.component('lnbits-admin-fiat-providers', {
return {
formAddStripeUser: '',
formAddPaypalUser: '',
formAddSquareUser: '',
formAddRevolutUser: '',
creatingRevolutWebhook: false,
hideInputToggle: true
}
},
@@ -23,12 +20,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal')
)
},
revolutWebhookUrl() {
return (
this.formData?.revolut_payment_webhook_url ||
this.calculateWebhookUrl('revolut')
)
}
},
watch: {
@@ -67,8 +58,6 @@ window.app.component('lnbits-admin-fiat-providers', {
syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
},
maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) {
@@ -88,47 +77,6 @@ window.app.component('lnbits-admin-fiat-providers', {
}
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() {
const addUser = this.formAddStripeUser || ''
if (
@@ -163,40 +111,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.paypal_limits.allowed_users =
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) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -210,48 +124,6 @@ window.app.component('lnbits-admin-fiat-providers', {
})
})
.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
})
}
}
})
@@ -202,18 +202,7 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd',
{
phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key',
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
phoenixd_api_password: 'Key'
}
],
[
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
]
}
},
methods: {
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
methods: {}
})
+8 -33
View File
@@ -545,56 +545,31 @@ window.PageAccount = {
if (file) {
this.uploadAsset(file)
}
e.target.value = null
},
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadAsset(
file,
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
) {
async uploadAsset(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
await LNbits.api.request(
'POST',
`/api/v1/assets?public_asset=${isPublic}`,
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
if (notifySuccess) {
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
}
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
await this.getUserAssets()
return data
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
},
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?')
+1 -1
View File
@@ -21,7 +21,7 @@ window.PageHome = {
return (
this.lnurl !== '' &&
this.g.settings.allowRegister &&
this.g.settings.authMethods.includes('user-id-only')
'user-id-only' in this.g.settings.authMethods
)
},
formatDescription() {
+153 -294
View File
File diff suppressed because it is too large Load Diff
@@ -587,543 +587,12 @@
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.square_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
<div class="row items-center">Disabled</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.square_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
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="hideInputToggle ? 'password' : 'text'"
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-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="hideInputToggle ? 'password' : 'text'"
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-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
@@ -1171,7 +640,9 @@
>
</div>
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">Square</div>
<div class="text-bold" style="min-width: 140px">
Square (coming soon)
</div>
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
@@ -1182,22 +653,7 @@
>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"
>Checkout</q-chip
>
<q-chip dense color="positive" text-color="white" icon="check"
>Subscriptions</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: Revolut-supported countries</q-chip
>Regions: Global</q-chip
>
</div>
</div>
+29 -29
View File
@@ -75,35 +75,7 @@
</p>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<div class="row q-col-gutter-md q-mt-lg">
<div class="row q-col-gutter-md">
<div class="col-12">
<h6 class="q-my-none">
<span v-text="$t('routing_fee_reserve_calculations')"></span>
@@ -210,6 +182,34 @@
></q-input>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p>
@@ -52,7 +52,6 @@
:label="prop.label"
:hint="prop.hint"
:value="prop.value"
:readonly="prop.readonly || false"
>
<q-btn
v-if="prop.copy"
@@ -74,15 +73,6 @@
></q-btn>
</q-input>
</div>
<p
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
class="col-12 q-my-md"
>
<span>
<q-icon name="warning" color="orange" size="xs"></q-icon>
<span v-text="$t('phoenixd_warning')"></span>
</span>
</p>
</div>
<q-expansion-item
v-if="
@@ -251,27 +251,10 @@
type="text"
v-model="formData.lnbits_default_bgimage"
label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.adminBackgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
</q-input>
<input
type="file"
ref="adminBackgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
+68 -134
View File
@@ -492,28 +492,10 @@
siteCustomisationChanged({bgimageChoice: $event})
"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.backgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
<q-tooltip
><span v-text="$t('background_image')"></span
></q-tooltip>
</q-input>
<input
type="file"
ref="backgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-mb-md">
@@ -1126,130 +1108,82 @@
<q-separator></q-separator>
<q-card-section>
<div
class="row items-center no-wrap q-col-gutter-sm"
<q-btn-dropdown
color="grey"
dense
outline
no-caps
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
>
<div class="col">
<q-btn-dropdown
color="grey"
dense
outline
no-caps
class="full-width"
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
<q-list>
<q-item
clickable
v-close-popup
@click="copyAssetLinkToClipboard(props.row)"
>
<q-list>
<q-item
clickable
v-close-popup
@click="
copyAssetLinkToClipboard(props.row)
"
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="
toggleAssetPublicAccess(props.row)
<q-item
clickable
v-close-popup
@click="toggleAssetPublicAccess(props.row)"
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section v-if="props.row.is_public">
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset private</q-item-label
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section
v-if="props.row.is_public"
>
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset
private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset
public</q-item-label
>
</q-item-section>
</q-item>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset public</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
dense
flat
round
icon="image"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-tooltip>Full image</q-tooltip>
</q-btn>
</div>
<div
class="col-auto"
v-if="props.row.thumbnail_base64"
>
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="secondary"
dense
flat
round
icon="photo_size_select_small"
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
>
<q-tooltip>Thumbnail</q-tooltip>
</q-btn>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-card-section>
</q-card>
</div>
-38
View File
@@ -394,44 +394,6 @@
<span v-text="$t('pay_with', {provider: 'PayPal'})"></span>
</q-item-section>
</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>
</div>
+2 -52
View File
@@ -4,11 +4,9 @@ import hashlib
import json
import urllib.parse
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any
import httpx
from embit.bip39 import mnemonic_is_valid
from httpx import RequestError, TimeoutException
from loguru import logger
from websockets import connect
@@ -63,8 +61,6 @@ class PhoenixdWallet(Wallet):
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
self._seed_mnemonic_to_persist: str | None = None
self._load_mnemonic_from_seed_file()
async def cleanup(self):
try:
@@ -73,7 +69,6 @@ class PhoenixdWallet(Wallet):
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
await self._persist_loaded_mnemonic()
try:
r = await self.client.get("/getinfo", timeout=10)
r.raise_for_status()
@@ -106,6 +101,7 @@ class PhoenixdWallet(Wallet):
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
try:
msats_amount = amount
data: dict[str, Any] = {
@@ -313,7 +309,7 @@ class PhoenixdWallet(Wallet):
and message_json.get("type") == "payment_received"
):
logger.info(
f"payment-received: {message_json['paymentHash']}"
f'payment-received: {message_json["paymentHash"]}'
)
yield message_json["paymentHash"]
@@ -323,49 +319,3 @@ class PhoenixdWallet(Wallet):
"retrying in 5 seconds"
)
await asyncio.sleep(5)
def _load_mnemonic_from_seed_file(self):
data_dir = settings.phoenixd_data_dir
if not data_dir:
return
seed_path = Path(data_dir).expanduser() / "seed.dat"
if not seed_path.is_file():
return
try:
mnemonic = seed_path.read_text(encoding="utf-8").strip()
if mnemonic == settings.phoenixd_mnemonic:
return
except OSError as exc:
logger.warning(f"Failed to read Phoenixd seed file '{seed_path}': {exc}")
return
if not mnemonic:
logger.warning(f"Phoenixd seed file '{seed_path}' is empty.")
return
if not mnemonic_is_valid(mnemonic):
logger.warning(
f"Phoenixd seed file '{seed_path}' does not contain a valid "
"BIP39 mnemonic."
)
return
settings.phoenixd_mnemonic = mnemonic
self._seed_mnemonic_to_persist = mnemonic
async def _persist_loaded_mnemonic(self):
if not self._seed_mnemonic_to_persist:
return
logger.info("Updating 'PHOENIXD_MNEMONIC' mnemonic settings.")
try:
from lnbits.core.crud.settings import set_settings_field
await set_settings_field(
"phoenixd_mnemonic", self._seed_mnemonic_to_persist
)
self._seed_mnemonic_to_persist = None
except Exception as exc:
logger.warning(f"Failed to persist Phoenixd mnemonic: {exc}")
+8 -12
View File
@@ -6,7 +6,7 @@
"": {
"name": "lnbits",
"dependencies": {
"axios": "^1.15.0",
"axios": "^1.13.5",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
@@ -734,14 +734,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@@ -1555,13 +1555,9 @@
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pyright": {
"version": "1.1.289",
+1 -1
View File
@@ -21,7 +21,7 @@
"terser": "^5.44.1"
},
"dependencies": {
"axios": "^1.15.0",
"axios": "^1.13.5",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
Generated
+2 -84
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -3388,88 +3388,6 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyinstrument"
version = "5.1.2"
description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"},
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75a7e17377d4405666bbaf126b1fd7bbb7e206d7246e6db3d62864d3d4790ae3"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5381cc6583d26e04d9298acded4242f4fe71986f1472c8aee6992c6816f0cac5"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ec08a530bef8d3492d31d8b0b12d0cfde09539f2a1c4b9678662ebc3c843e478"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d671168508129b472be570bc9aee361190ba917b997c703bd134bb4de445ce7"},
{file = "pyinstrument-5.1.2-cp310-cp310-win32.whl", hash = "sha256:5957a94f84564b374a7f856d1b322345d600964280b0d687b8ddcc483f21e576"},
{file = "pyinstrument-5.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:38a2180a7801c51610b50e5d423674b21872efd019ccf05a11b7f9016cb1dcfc"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3739a05583ea6312c385eb59fe985cd20d9048e95f9eeeb6a2f6c35202e2d36e"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c9ee05dc75ac5fb18498c311e624f77f7f321f7ff325b251aa09e52e46f1d6a"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a49a55ca5b75218767e29cacbe515d0b66fc18cb48a937bca0f77b8dafc7202"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c45c14974ff04b1bfdc6c2a448627c6da7409c7800d0eb7bd03fb435dcb41d7"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22b9c04b3982c41c04b1c5ed05d1bc3a2ba26533450084058119f6dc160e70a3"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c4995ee0774801790c138f0dfec17d4e7a7ef09a6d56d53cbcbf0578a711021"},
{file = "pyinstrument-5.1.2-cp311-cp311-win32.whl", hash = "sha256:fe449e4a8ee60a2a27cf509350a584670f4c3704649601be7937598f09dbe7ca"},
{file = "pyinstrument-5.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:3fb839429671a42bf349335af4c1ce5cf83386ac11f04df0bc40720d4cb7d77d"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d"},
{file = "pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281"},
{file = "pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e"},
{file = "pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0"},
{file = "pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6"},
{file = "pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b"},
{file = "pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:47f14f248108f1202d48f34903bddc053a47c62ce46908aee848c1f667a1925b"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc3f2688981af764fa2c5f5a00d7040c0c12771ca5a026730f2d826f7a28d277"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7afb24d11d24fb1762059240ed97a1a4607779d5f221f91d62b67ae089bd506d"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d21221a29718d5dcdc1453dbbbdd100734525672ef1e34ff03d49bc5d688ca4"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3b68aa9f0d5217c67370762c99a80750ce56f66e5e9281c31b5baa3e4b88894d"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:755702f01800934c3bec3c0d30483f97b6ff1fc1d2afcf6d816584703c9f1235"},
{file = "pyinstrument-5.1.2-cp38-cp38-win32.whl", hash = "sha256:2bacb980c95d4c9ea6a253e5ccf3e99993082de29ff7e7fc397e9484355577b1"},
{file = "pyinstrument-5.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:2588bc34c25d50f29d3a117c7dfac06513ee59c507b65081e66ca9569bcf45e0"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bea0687665c181c6e62677fb560739a473c4286816582a43f8eb0aa6094ed529"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:64246d2bd475870b62ed5df4808bfb33328135e8dfbdff823f9cb7d1358eb40b"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff6fd3c7907e57f082cdd405bec1b34768c1810a165538299d259ee1bff5d7b6"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af09af38ee8407ca273407e24a8e6470d2444561d01005ee6a8db5f2fd908c08"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1249d2799cdd57151b4444167e5c7736e2c9b5e79bd781b0779d631338509553"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d653206f50260f20bc78339c3d7aa0f19f8cf9c9f71939fbf02e2ea30353487"},
{file = "pyinstrument-5.1.2-cp39-cp39-win32.whl", hash = "sha256:d0b0c6e289725f14d0ff73f8190c953bdcb98f21c5c29c3eafb0dca8025583cb"},
{file = "pyinstrument-5.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:db8243e602aca43dc7ce8e40ed7d0ca4820d024c3c03824870c5a9e98f84e953"},
{file = "pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0"},
]
[package.extras]
bin = ["click", "nox"]
docs = ["furo (==2024.7.18)", "myst-parser (==3.0.1)", "sphinx (==7.4.7)", "sphinx-autobuild (==2024.4.16)", "sphinxcontrib-programoutput (==0.17)"]
examples = ["django", "litestar", "numpy"]
test = ["cffi (>=1.17.0)", "flaky", "greenlet (>=3)", "ipython", "pytest", "pytest-asyncio (==0.23.8)", "trio"]
types = ["typing_extensions"]
[[package]]
name = "pyjwt"
version = "2.12.1"
@@ -5110,4 +5028,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "0879a6230bbc0d0e1d8d7d090e1572ba863078b18e0d78478baf2100aa245e08"
content-hash = "42751c05e1fa8250ff90981def5e0ce8d1b284eeb2acc2c7ac9ae5847cf99e17"
+2 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.4"
version = "1.5.3"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -82,8 +82,7 @@ dev = [
"pytest-mock~=3.15.1",
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"pyinstrument>=5.1.2",
"grpcio-tools~=1.76.0"
]
[tool.poetry]
-42
View File
@@ -135,8 +135,6 @@ async def test_auth_api_first_install_success_and_validation(
)
assert success.status_code == 200
assert success.json()["access_token"]
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" not in success.headers["set-cookie"]
updated_superuser = await get_account(settings.super_user, active_only=False)
assert updated_superuser is not None
@@ -161,43 +159,3 @@ async def test_auth_api_first_install_success_and_validation(
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
@pytest.mark.anyio
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_auth_https_only = settings.auth_https_only
new_username = f"secure_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.auth_https_only = True
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert success.status_code == 200
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" in success.headers["set-cookie"]
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.auth_https_only = original_auth_https_only
+2 -352
View File
@@ -4,18 +4,13 @@ from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice, Payment
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import (
handle_paypal_event,
handle_revolut_event,
handle_square_event,
handle_stripe_event,
)
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import Settings
@pytest.mark.anyio
@@ -28,15 +23,7 @@ async def test_callback_api_generic_webhook_handler_routes_providers(
paypal_mock = mocker.patch(
"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_square_signature")
mocker.patch("lnbits.core.views.callback_api.check_revolut_signature")
mocker.patch(
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
)
@@ -58,27 +45,6 @@ async def test_callback_api_generic_webhook_handler_routes_providers(
assert paypal.json()["success"] is True
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"})
assert unknown.status_code == 200
assert unknown.json()["success"] is False
@@ -129,150 +95,7 @@ async def test_callback_api_handles_paid_events_with_real_payments(mocker):
@pytest.mark.anyio
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
):
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]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
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_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
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": "SUBSCRIPTION_INITIATED",
"subscription_id": "SUBSCRIPTION_1",
}
)
assert create_fiat_invoice_mock.await_count == 1
revolut_call = create_fiat_invoice_mock.await_args.kwargs
assert revolut_call["wallet_id"] == wallet.id
invoice = revolut_call["invoice_data"]
assert invoice.fiat_provider == "revolut"
assert invoice.amount == 9.25
assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "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
):
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
@@ -340,99 +163,6 @@ async def test_callback_api_handles_subscription_flows_and_validation(
)
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(
ValueError, match="PayPal subscription event missing custom metadata."
):
@@ -443,83 +173,3 @@ async def test_callback_api_handles_subscription_flows_and_validation(
"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,8 +4,6 @@ from pytest_mock.plugin import MockerFixture
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat.base import FiatSubscriptionResponse
from lnbits.fiat.revolut import REVOLUT_WEBHOOK_EVENTS
from lnbits.settings import Settings
class _UnsetSecret:
@@ -146,82 +144,3 @@ async def test_fiat_api_connection_token_validates_provider_configuration(
assert ok.status_code == 200
assert ok.json() == {"secret": "tok_live"}
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()
+3 -58
View File
@@ -4,10 +4,9 @@ from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import Account, CreateInvoice, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
@@ -22,7 +21,7 @@ from lnbits.core.views.payment_api import (
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filter, Filters
from lnbits.db import Filters
from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = (
@@ -92,60 +91,6 @@ async def test_payment_api_stats_and_all_paginated(admin_user):
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
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
+1 -3
View File
@@ -40,9 +40,7 @@ from tests.helpers import (
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
ADMIN_USER_ID = uuid4().hex
# Snapshot the initialized module settings instead of a fresh Settings() instance.
# The module settings include runtime-populated values like `version`.
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS = Settings()
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
-1
View File
@@ -1 +0,0 @@
98ae578877a3479bb0424d2e418b427a
-1
View File
@@ -1 +0,0 @@
6355d3c6c5df49dbb562a74f9deb19ce
+1 -3
View File
@@ -28,7 +28,7 @@ from lnbits.core.models.extensions_builder import (
PublicPageFields,
SettingsFields,
)
from lnbits.wallets import get_funding_source, set_funding_source
from lnbits.wallets import get_funding_source
class DbTestModel(BaseModel):
@@ -182,8 +182,6 @@ def make_lnurl_pay_response(
)
set_funding_source()
funding_source = get_funding_source()
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake
+9 -1
View File
@@ -9,7 +9,15 @@ from loguru import logger
from lnbits.wallets import get_funding_source
funding_source = get_funding_source()
is_boltz_wallet = funding_source.__class__.__name__ == "BoltzWallet"
def is_boltz_wallet():
print(
"### funding_source.__class__.__name__ 2",
get_funding_source().__class__.__name__,
)
return get_funding_source().__class__.__name__ == "BoltzWallet"
docker_lightning_cli = [
"docker",
@@ -21,7 +21,8 @@ async def test_create_invoice(from_wallet):
)
# we cannot know the preimage of the swap yet
if not is_boltz_wallet:
funding_source = get_funding_source()
if not is_boltz_wallet():
assert payment.preimage
invoice = decode(payment.bolt11)
@@ -42,7 +43,8 @@ async def test_create_internal_invoice(from_wallet):
)
# we cannot know the preimage of the swap yet
if not is_boltz_wallet:
funding_source = get_funding_source()
if not is_boltz_wallet():
assert payment.preimage
invoice = decode(payment.bolt11)
+1 -1
View File
@@ -20,7 +20,7 @@ async def test_services_pay_invoice(to_wallet, real_invoice):
)
assert payment
assert payment.memo == description
if not is_boltz_wallet:
if not is_boltz_wallet():
assert payment.status == PaymentState.SUCCESS
assert payment.preimage
else:
+12 -66
View File
@@ -1,32 +1,7 @@
import pytest
from lnbits.db import Filters
from tests.helpers import DbTestModel
TEST_DB_FETCH_PAGE_ROWS: tuple[dict[str, str], ...] = (
{"id": "1", "name": "Alice", "value": "foo"},
{"id": "2", "name": "Bob", "value": "bar"},
{"id": "3", "name": "Carol", "value": "bar"},
{"id": "4", "name": "Dave", "value": "bar"},
{"id": "5", "name": "Dave", "value": "foo"},
{"id": "6", "name": "Eve", "value": "foo"},
{"id": "7", "name": "Frank", "value": "bar"},
{"id": "8", "name": "Grace", "value": "foo"},
{"id": "9", "name": "Heidi", "value": "bar"},
{"id": "10", "name": "Ivan", "value": "foo"},
{"id": "11", "name": "Judy", "value": "bar"},
{"id": "12", "name": "Mallory", "value": "foo"},
{"id": "13", "name": "Niaj", "value": "bar"},
{"id": "14", "name": "Olivia", "value": "foo"},
{"id": "15", "name": "Peggy", "value": "bar"},
{"id": "16", "name": "Rupert", "value": "foo"},
{"id": "17", "name": "Sybil", "value": "bar"},
{"id": "18", "name": "Trent", "value": "foo"},
{"id": "19", "name": "Victor", "value": "bar"},
{"id": "20", "name": "Walter", "value": "foo"},
{"id": "21", "name": "Zoe", "value": "bar"},
)
@pytest.fixture(scope="session")
async def fetch_page(db):
@@ -38,14 +13,14 @@ async def fetch_page(db):
name TEXT NOT NULL
)
""")
for row in TEST_DB_FETCH_PAGE_ROWS:
await db.execute(
"""
INSERT INTO test_db_fetch_page (id, name, value)
VALUES (:id, :name, :value)
""",
row,
)
await db.execute("""
INSERT INTO test_db_fetch_page (id, name, value) VALUES
('1', 'Alice', 'foo'),
('2', 'Bob', 'bar'),
('3', 'Carol', 'bar'),
('4', 'Dave', 'bar'),
('5', 'Dave', 'foo')
""")
yield
await db.execute("DROP TABLE test_db_fetch_page")
@@ -58,35 +33,8 @@ async def test_db_fetch_page_simple(fetch_page, db):
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == Filters().limit
@pytest.mark.anyio
async def test_db_fetch_page_limit_zero_returns_all(fetch_page, db):
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=0),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == len(TEST_DB_FETCH_PAGE_ROWS)
@pytest.mark.anyio
async def test_db_fetch_page_limit(fetch_page, db):
limit = 5
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=limit),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == limit
assert row.total == 5
assert len(row.data) == 5
@pytest.mark.anyio
@@ -97,7 +45,7 @@ async def test_db_fetch_page_group_by(fetch_page, db):
group_by=["name"],
)
assert row
assert row.total == len({test_row["name"] for test_row in TEST_DB_FETCH_PAGE_ROWS})
assert row.total == 4
@pytest.mark.anyio
@@ -108,9 +56,7 @@ async def test_db_fetch_page_group_by_multiple(fetch_page, db):
group_by=["value", "name"],
)
assert row
assert row.total == len(
{(test_row["value"], test_row["name"]) for test_row in TEST_DB_FETCH_PAGE_ROWS}
)
assert row.total == 5
@pytest.mark.anyio
+1 -807
View File
@@ -1,8 +1,6 @@
import hashlib
import hmac
import json
import time
from base64 import b64encode
from unittest.mock import AsyncMock
import pytest
@@ -17,8 +15,6 @@ from lnbits.core.models.wallets import Wallet
from lnbits.core.services import check_payment_status, payments
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature,
handle_fiat_payment_confirmation,
verify_paypal_webhook,
@@ -27,14 +23,7 @@ from lnbits.core.services.fiat_providers import (
test_connection as fiat_provider_connection,
)
from lnbits.core.services.users import create_user_account
from lnbits.fiat.base import (
FiatInvoiceResponse,
FiatPaymentStatus,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
)
from lnbits.fiat.revolut import REVOLUT_WEBHOOK_EVENTS, RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus, FiatStatusResponse
from lnbits.settings import Settings
from tests.helpers import get_random_string
@@ -67,56 +56,16 @@ class MockHTTPClient:
self.calls.append((path, kwargs))
return self._responses.pop(0)
async def get(self, path: str, **kwargs):
self.calls.append((path, kwargs))
return self._responses.pop(0)
@pytest.fixture(autouse=True)
def fiat_provider_test_settings(settings: Settings):
original_allowed_currencies = settings.lnbits_allowed_currencies
original_paypal_enabled = settings.paypal_enabled
original_square_enabled = settings.square_enabled
original_square_api_endpoint = settings.square_api_endpoint
original_square_access_token = settings.square_access_token
original_square_location_id = settings.square_location_id
original_square_api_version = settings.square_api_version
original_square_payment_success_url = settings.square_payment_success_url
original_square_payment_webhook_url = settings.square_payment_webhook_url
original_square_webhook_signature_key = settings.square_webhook_signature_key
original_square_limits = settings.square_limits.copy(deep=True)
original_revolut_enabled = settings.revolut_enabled
original_revolut_api_endpoint = settings.revolut_api_endpoint
original_revolut_api_secret_key = settings.revolut_api_secret_key
original_revolut_api_version = settings.revolut_api_version
original_revolut_payment_success_url = settings.revolut_payment_success_url
original_revolut_payment_webhook_url = settings.revolut_payment_webhook_url
original_revolut_webhook_signing_secret = settings.revolut_webhook_signing_secret
original_revolut_limits = settings.revolut_limits.copy(deep=True)
settings.lnbits_allowed_currencies = []
settings.paypal_enabled = False
settings.square_enabled = False
settings.revolut_enabled = False
yield
settings.lnbits_allowed_currencies = original_allowed_currencies
settings.paypal_enabled = original_paypal_enabled
settings.square_enabled = original_square_enabled
settings.square_api_endpoint = original_square_api_endpoint
settings.square_access_token = original_square_access_token
settings.square_location_id = original_square_location_id
settings.square_api_version = original_square_api_version
settings.square_payment_success_url = original_square_payment_success_url
settings.square_payment_webhook_url = original_square_payment_webhook_url
settings.square_webhook_signature_key = original_square_webhook_signature_key
settings.square_limits = original_square_limits
settings.revolut_enabled = original_revolut_enabled
settings.revolut_api_endpoint = original_revolut_api_endpoint
settings.revolut_api_secret_key = original_revolut_api_secret_key
settings.revolut_api_version = original_revolut_api_version
settings.revolut_payment_success_url = original_revolut_payment_success_url
settings.revolut_payment_webhook_url = original_revolut_payment_webhook_url
settings.revolut_webhook_signing_secret = original_revolut_webhook_signing_secret
settings.revolut_limits = original_revolut_limits
@pytest.mark.anyio
@@ -181,39 +130,6 @@ async def test_create_wallet_fiat_invoice_allowed_users(
assert user
assert user.fiat_providers == []
settings.square_enabled = True
settings.square_limits.allowed_users = []
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["square"]
settings.square_limits.allowed_users = ["some_other_user_id"]
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == []
settings.square_limits.allowed_users.append(to_user.id)
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["square"]
settings.square_enabled = False
settings.revolut_enabled = True
settings.revolut_limits.allowed_users = []
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["revolut"]
settings.revolut_limits.allowed_users = ["some_other_user_id"]
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == []
settings.revolut_limits.allowed_users.append(to_user.id)
user = await get_user(to_user.id)
assert user
assert user.fiat_providers == ["revolut"]
@pytest.mark.anyio
async def test_create_wallet_fiat_invoice_fiat_limits_fail(
@@ -364,703 +280,6 @@ async def test_create_wallet_fiat_invoice_success(
assert status.success is True
@pytest.mark.anyio
async def test_create_wallet_square_fiat_invoice_success(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.square_enabled = True
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
settings.square_limits.service_min_amount_sats = 0
settings.square_limits.service_max_amount_sats = 0
settings.square_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="square"
)
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id="order_123",
payment_request="https://square.link/u/session_123",
)
mocker.patch(
"lnbits.fiat.SquareWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000),
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.status == PaymentState.PENDING
assert payment.fiat_provider == "square"
assert payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id
assert payment.checking_id.startswith("fiat_square_order_123")
@pytest.mark.anyio
async def test_square_wallet_create_invoice(settings: Settings):
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"
settings.square_payment_success_url = "https://lnbits.example/success"
wallet = SquareWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"payment_link": {
"order_id": "ORDER123",
"url": "https://square.link/u/abc123",
}
}
)
]
)
wallet.client = client # type: ignore[assignment]
response = await wallet.create_invoice(
amount=1.23,
payment_hash="hash123",
currency="USD",
memo="LNbits Square invoice",
extra={"checkout": {"metadata": {"source": "test"}}},
)
assert response.ok is True
assert response.checking_id == "order_ORDER123"
assert response.payment_request == "https://square.link/u/abc123"
assert client.calls[0][0] == "/v2/online-checkout/payment-links"
payload = client.calls[0][1]["json"]
assert payload["idempotency_key"] == "hash123"
assert payload["order"]["location_id"] == "LOC123"
assert payload["order"]["metadata"]["payment_hash"] == "hash123"
assert payload["order"]["metadata"]["alan_action"] == "invoice"
assert payload["order"]["metadata"]["source"] == "test"
assert payload["order"]["line_items"][0]["base_price_money"]["amount"] == 123
@pytest.mark.anyio
async def test_square_wallet_create_subscription(settings: Settings):
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"
settings.square_payment_success_url = "https://lnbits.example/success"
wallet = SquareWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"object": {
"type": "SUBSCRIPTION_PLAN_VARIATION",
"id": "PLAN_VARIATION_123",
"subscription_plan_variation_data": {
"phases": [
{
"ordinal": 0,
"pricing": {
"type": "STATIC",
"price_money": {
"amount": 1500,
"currency": "USD",
},
},
}
]
},
}
}
),
MockHTTPResponse(
json_data={
"payment_link": {
"id": "plink_123",
"url": "https://square.link/u/sub_123",
}
}
),
]
)
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
memo="Monthly Gold",
tag="gold",
extra={"link": "link-1"},
success_url="https://lnbits.example/subscription-success",
)
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is True
assert response.checkout_session_url == "https://square.link/u/sub_123"
assert response.subscription_request_id is not None
assert client.calls[0][0] == "/v2/catalog/object/PLAN_VARIATION_123"
assert client.calls[1][0] == "/v2/online-checkout/payment-links"
payload = client.calls[1][1]["json"]
assert payload["idempotency_key"] == response.subscription_request_id
assert payload["quick_pay"]["location_id"] == "LOC123"
assert payload["quick_pay"]["price_money"] == {"amount": 1500, "currency": "USD"}
assert payload["checkout_options"] == {
"redirect_url": "https://lnbits.example/subscription-success",
"subscription_plan_id": "PLAN_VARIATION_123",
}
metadata = json.loads(payload["payment_note"])
assert metadata[:3] == ["wallet_1", "gold", response.subscription_request_id]
assert metadata[3:] == ["link-1", "Monthly Gold"]
@pytest.mark.anyio
async def test_square_wallet_create_subscription_from_plan_id(settings: Settings):
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"
settings.square_payment_success_url = "https://lnbits.example/success"
wallet = SquareWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"object": {
"type": "SUBSCRIPTION_PLAN",
"id": "PLAN123",
"subscription_plan_data": {
"name": "LNbits Test Weekly Personal Plan",
"subscription_plan_variations": [
{
"type": "SUBSCRIPTION_PLAN_VARIATION",
"id": "PLAN_VARIATION_123",
"subscription_plan_variation_data": {
"name": "LNbits Test Weekly Personal Plan",
"phases": [
{
"uid": "PHASE123",
"cadence": "WEEKLY",
"ordinal": 0,
"pricing": {"type": "RELATIVE"},
}
],
"subscription_plan_id": "PLAN123",
},
}
],
"eligible_item_ids": ["ITEM123"],
"all_items": False,
},
}
}
),
MockHTTPResponse(
json_data={
"object": {
"type": "ITEM",
"id": "ITEM123",
"item_data": {
"name": "LNbits Test Weekly Personal Plan",
"variations": [
{
"type": "ITEM_VARIATION",
"id": "ITEM_VARIATION_123",
"item_variation_data": {
"item_id": "ITEM123",
"name": "Regular",
"pricing_type": "FIXED_PRICING",
"price_money": {
"amount": 1500,
"currency": "USD",
},
},
}
],
},
}
}
),
MockHTTPResponse(
json_data={
"payment_link": {
"id": "plink_123",
"url": "https://square.link/u/sub_123",
}
}
),
]
)
wallet.client = client # type: ignore[assignment]
response = await wallet.create_subscription(
"PLAN123",
1,
FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
memo="Weekly Plan",
success_url="https://lnbits.example/success",
),
)
assert response.ok is True
assert client.calls[0][0] == "/v2/catalog/object/PLAN123"
assert client.calls[1][0] == "/v2/catalog/object/ITEM123"
assert client.calls[2][0] == "/v2/online-checkout/payment-links"
payload = client.calls[2][1]["json"]
assert payload["quick_pay"]["price_money"] == {"amount": 1500, "currency": "USD"}
assert payload["checkout_options"] == {
"redirect_url": "https://lnbits.example/success",
"subscription_plan_id": "PLAN_VARIATION_123",
}
@pytest.mark.anyio
async def test_square_wallet_create_subscription_invoice(settings: Settings):
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"
wallet = SquareWallet()
response = await wallet.create_invoice(
amount=15,
payment_hash="hash123",
currency="USD",
memo="Square subscription payment",
extra={
"fiat_method": "subscription",
"subscription": {
"checking_id": "payment_PAYMENT123",
"payment_request": "https://square.example/invoice",
},
},
)
assert response.ok is True
assert response.checking_id == "payment_PAYMENT123"
assert response.payment_request == "https://square.example/invoice"
@pytest.mark.anyio
async def test_square_wallet_cancel_subscription(settings: Settings):
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"
wallet = SquareWallet()
client = MockHTTPClient([MockHTTPResponse(json_data={"subscription": {}})])
wallet.client = client # type: ignore[assignment]
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
assert response.ok is True
assert client.calls[0][0] == "/v2/subscriptions/SUBSCRIPTION123/cancel"
@pytest.mark.anyio
async def test_square_wallet_cancel_subscription_by_request_id(
settings: Settings, mocker: MockerFixture
):
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"
wallet = SquareWallet()
client = MockHTTPClient([MockHTTPResponse(json_data={"subscription": {}})])
wallet.client = client # type: ignore[assignment]
payment = Payment(
checking_id="fiat_square_payment_PAYMENT123",
payment_hash="hash123",
wallet_id="wallet_1",
amount=1000,
fee=0,
bolt11="lnbc1square",
fiat_provider="square",
extra={"subscription_request_id": "REQUEST123"},
external_id="SUBSCRIPTION123",
)
get_payments_mock = mocker.patch(
"lnbits.core.crud.payments.get_payments",
AsyncMock(side_effect=[[], [payment]]),
)
response = await wallet.cancel_subscription("REQUEST123", "wallet_1")
assert response.ok is True
assert client.calls[0][0] == "/v2/subscriptions/SUBSCRIPTION123/cancel"
assert get_payments_mock.await_count == 2
@pytest.mark.anyio
async def test_square_wallet_get_invoice_status(settings: Settings):
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"
wallet = SquareWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"order": {
"id": "ORDER123",
"state": "COMPLETED",
"tenders": [{"payment_id": "PAYMENT123"}],
}
}
),
MockHTTPResponse(json_data={"payment": {"status": "COMPLETED"}}),
]
)
wallet.client = client # type: ignore[assignment]
status = await wallet.get_invoice_status("fiat_square_order_ORDER123")
assert status.success is True
assert client.calls[0][0] == "/v2/orders/ORDER123"
assert client.calls[1][0] == "/v2/payments/PAYMENT123"
@pytest.mark.anyio
async def test_create_wallet_revolut_fiat_invoice_success(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.revolut_enabled = True
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_limits.service_min_amount_sats = 0
settings.revolut_limits.service_max_amount_sats = 0
settings.revolut_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="revolut"
)
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id="order_ORDER123",
payment_request="https://checkout.revolut.com/payment-link/ORDER123",
)
mocker.patch(
"lnbits.fiat.RevolutWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000),
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.status == PaymentState.PENDING
assert payment.fiat_provider == "revolut"
assert payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id
assert payment.checking_id.startswith("fiat_revolut_order_ORDER123")
@pytest.mark.anyio
async def test_revolut_wallet_create_invoice(settings: Settings):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
settings.revolut_payment_success_url = "https://lnbits.example/success"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"id": "ORDER123",
"checkout_url": "https://checkout.revolut.com/payment-link/abc123",
}
)
]
)
wallet.client = client # type: ignore[assignment]
response = await wallet.create_invoice(
amount=1.23,
payment_hash="hash123",
currency="USD",
memo="LNbits Revolut invoice",
extra={"checkout": {"metadata": {"source": "test"}}},
)
assert response.ok is True
assert response.checking_id == "order_ORDER123"
assert (
response.payment_request == "https://checkout.revolut.com/payment-link/abc123"
)
assert client.calls[0][0] == "/api/orders"
payload = client.calls[0][1]["json"]
assert payload["amount"] == 123
assert payload["currency"] == "USD"
assert payload["metadata"]["payment_hash"] == "hash123"
assert payload["metadata"]["alan_action"] == "invoice"
assert payload["metadata"]["source"] == "test"
assert payload["redirect_url"] == "https://lnbits.example/success"
@pytest.mark.anyio
async def test_revolut_wallet_get_invoice_status(settings: Settings):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient([MockHTTPResponse(json_data={"state": "COMPLETED"})])
wallet.client = client # type: ignore[assignment]
status = await wallet.get_invoice_status("fiat_revolut_order_ORDER123")
assert status.success is True
assert client.calls[0][0] == "/api/orders/ORDER123"
@pytest.mark.anyio
async def test_revolut_wallet_create_subscription(settings: Settings):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
settings.revolut_payment_success_url = "https://lnbits.example/subscription-success"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"id": "SUBSCRIPTION123",
"setup_order_id": "ORDER123",
}
),
MockHTTPResponse(
json_data={
"id": "ORDER123",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_123",
}
),
]
)
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
memo="Monthly Gold",
tag="gold",
extra={"customer_id": "CUSTOMER123", "link": "link-1"},
success_url="https://lnbits.example/subscription-success",
)
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is True
assert response.subscription_request_id == "SUBSCRIPTION123"
assert (
response.checkout_session_url
== "https://checkout.revolut.com/payment-link/sub_123"
)
assert client.calls[0][0] == "/api/subscriptions"
payload = client.calls[0][1]["json"]
assert payload["plan_variation_id"] == "PLAN_VARIATION_123"
assert payload["customer_id"] == "CUSTOMER123"
assert payload["setup_order_redirect_url"] == (
"https://lnbits.example/subscription-success"
)
reference = json.loads(payload["external_reference"])
assert reference["wallet_id"] == "wallet_1"
assert reference["tag"] == "gold"
assert reference["memo"] == "Monthly Gold"
assert reference["extra"]["customer_id"] == "CUSTOMER123"
assert client.calls[1][0] == "/api/orders/ORDER123"
@pytest.mark.anyio
async def test_revolut_wallet_cancel_subscription(settings: Settings):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient([MockHTTPResponse(json_data={})])
wallet.client = client # type: ignore[assignment]
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
assert response.ok is True
assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123/cancel"
@pytest.mark.anyio
async def test_revolut_wallet_create_webhook(mocker: MockerFixture):
client = MockHTTPClient(
[
MockHTTPResponse({"webhooks": []}),
MockHTTPResponse(
{
"id": "webhook_1",
"url": "https://lnbits.example/api/v1/callback/revolut",
"events": REVOLUT_WEBHOOK_EVENTS,
"signing_secret": "whsec_1",
}
),
]
)
async_client = mocker.patch("lnbits.fiat.revolut.httpx.AsyncClient")
async_client.return_value = client
response = await RevolutWallet.create_webhook(
url="https://lnbits.example/api/v1/callback/revolut",
endpoint="https://sandbox-merchant.revolut.com",
api_secret_key="revolut-secret",
api_version="2026-04-20",
)
assert response["signing_secret"] == "whsec_1"
async_client.assert_called_once()
assert async_client.call_args.kwargs["base_url"] == (
"https://sandbox-merchant.revolut.com"
)
assert async_client.call_args.kwargs["headers"]["Authorization"] == (
"Bearer revolut-secret"
)
assert client.calls == [
(
"/api/webhooks",
{
"timeout": 15,
},
),
(
"/api/webhooks",
{
"json": {
"url": "https://lnbits.example/api/v1/callback/revolut",
"events": REVOLUT_WEBHOOK_EVENTS,
},
"timeout": 15,
},
),
]
@pytest.mark.anyio
async def test_revolut_wallet_reuses_existing_webhook(mocker: MockerFixture):
client = MockHTTPClient(
[
MockHTTPResponse(
{
"webhooks": [
{
"id": "webhook_1",
"url": "https://lnbits.example/api/v1/callback/revolut",
"events": REVOLUT_WEBHOOK_EVENTS,
"signing_secret": "whsec_1",
}
]
}
)
]
)
async_client = mocker.patch("lnbits.fiat.revolut.httpx.AsyncClient")
async_client.return_value = client
response = await RevolutWallet.create_webhook(
url="https://lnbits.example/api/v1/callback/revolut",
endpoint="https://sandbox-merchant.revolut.com",
api_secret_key="revolut-secret",
api_version="2026-04-20",
)
assert response["already_exists"] is True
assert response["signing_secret"] == "whsec_1"
assert client.calls == [
(
"/api/webhooks",
{
"timeout": 15,
},
)
]
@pytest.mark.anyio
async def test_revolut_wallet_rejects_local_webhook_url():
with pytest.raises(ValueError, match="clearnet URL"):
await RevolutWallet.create_webhook(
url="http://localhost:5000/api/v1/callback/revolut",
endpoint="https://sandbox-merchant.revolut.com",
api_secret_key="revolut-secret",
api_version="2026-04-20",
)
def test_check_revolut_signature():
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
timestamp = str(int(time.time()))
secret = "revolut-secret"
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
check_revolut_signature(payload, sig, timestamp, secret)
def test_check_revolut_signature_millisecond_timestamp():
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
timestamp = str(int(time.time() * 1000))
secret = "revolut-secret"
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
check_revolut_signature(payload, sig, timestamp, secret)
def test_check_revolut_signature_v1_header():
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
timestamp = str(int(time.time() * 1000))
secret = "revolut-secret"
signed_payload = b"v1." + timestamp.encode() + b"." + payload
sig = "v1=" + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
check_revolut_signature(payload, sig, timestamp, secret)
def test_check_revolut_signature_multiple_v1_headers():
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
timestamp = str(int(time.time() * 1000))
secret = "revolut-secret"
signed_payload = b"v1." + timestamp.encode() + b"." + payload
valid_sig = (
"v1=" + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
)
sig_header = f"v1=deadbeef,{valid_sig}"
check_revolut_signature(payload, sig_header, timestamp, secret)
def test_check_revolut_signature_docs_vector():
payload = (
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
b'"new_state":"completed","old_state":"pending",'
b'"request_id":"app_charges-9f5d5eb3-1e06-46c5-b1c0-3914763e0bcb"},'
b'"event":"TransactionStateChanged",'
b'"timestamp":"2023-05-09T16:36:38.028960Z"}'
)
timestamp = "1683650202360"
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
check_revolut_signature(
payload, sig, timestamp, secret, tolerance_seconds=100000000
)
@pytest.mark.anyio
async def test_fiat_service_fee(settings: Settings):
# settings.stripe_limits.service_min_amount_sats = 0
@@ -1250,31 +469,6 @@ def test_check_stripe_signature_non_utf8_payload():
check_stripe_signature(payload, sig_header, secret)
def test_check_square_signature_success():
payload = b'{"type":"payment.updated"}'
secret = "signature-key"
notification_url = "https://lnbits.example/api/v1/callback/square"
signature = b64encode(
hmac.new(
key=secret.encode(),
msg=notification_url.encode() + payload,
digestmod=hashlib.sha256,
).digest()
).decode()
check_square_signature(payload, signature, secret, notification_url)
def test_check_square_signature_rejects_invalid_signature():
with pytest.raises(ValueError, match="Square signature verification failed."):
check_square_signature(
b'{"type":"payment.updated"}',
"invalid-signature",
"signature-key",
"https://lnbits.example/api/v1/callback/square",
)
# Helper to generate a valid Stripe signature header
def _make_stripe_sig_header(payload, secret, timestamp=None):
if timestamp is None:
+1 -6
View File
@@ -252,12 +252,7 @@ async def test_notification_for_internal_payment(
assert _payment.bolt11 == payment.bolt11
assert _payment.amount == 123_000
updated_payment = await get_payment(_payment.checking_id)
assert (
updated_payment.webhook_status is not None
), "Webhook should have been called."
assert (
int(updated_payment.webhook_status) >= 400
), "Webhook should have been called and failed."
assert updated_payment.webhook_status == "404"
@pytest.mark.anyio
+1 -1
View File
@@ -197,7 +197,7 @@ async def test_update_wallet_balance_validates_credit_and_debit(
settings.lnbits_wallet_limit_max_balance = 0
queue_mock = mocker.patch(
"lnbits.tasks.internal_invoice_queue_put",
"lnbits.core.services.payments.internal_invoice_queue_put",
mocker.AsyncMock(),
)
Generated
+10 -44
View File
@@ -1263,7 +1263,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.4"
version = "1.5.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1334,7 +1334,6 @@ dev = [
{ name = "openai" },
{ name = "openapi-spec-validator" },
{ name = "pre-commit" },
{ name = "pyinstrument" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-httpserver" },
@@ -1409,7 +1408,6 @@ dev = [
{ name = "openai", specifier = "~=2.14.0" },
{ name = "openapi-spec-validator", specifier = "~=0.7.2" },
{ name = "pre-commit", specifier = "~=4.5.1" },
{ name = "pyinstrument", specifier = ">=5.1.2" },
{ name = "pytest", specifier = "~=9.0.2" },
{ name = "pytest-cov", specifier = "~=7.0.0" },
{ name = "pytest-httpserver", specifier = "~=1.1.3" },
@@ -2024,43 +2022,11 @@ email = [
[[package]]
name = "pygments"
version = "2.20.0"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyinstrument"
version = "5.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/74/c66e1bf3565600d78f53195efb6f8fd31610f85a58aa3fee39c56bf71d1b/pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6", size = 131470, upload-time = "2026-01-04T18:37:22.536Z" },
{ url = "https://files.pythonhosted.org/packages/1a/6b/606c5bfa311b5be74f58ef505c678216dda2be3b76a2ac770c2b0fccff77/pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236", size = 124567, upload-time = "2026-01-04T18:37:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/15/70/c8a88defb77873513971f590549c48ceb70f7ef10f30a689762ef36dd877/pyinstrument-5.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75a7e17377d4405666bbaf126b1fd7bbb7e206d7246e6db3d62864d3d4790ae3", size = 149205, upload-time = "2026-01-04T18:37:25.696Z" },
{ url = "https://files.pythonhosted.org/packages/8f/4b/0e64fefb939af472c3fbc63ab45224766447bde73f51579f3ecc335b0a49/pyinstrument-5.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5381cc6583d26e04d9298acded4242f4fe71986f1472c8aee6992c6816f0cac5", size = 147900, upload-time = "2026-01-04T18:37:27.343Z" },
{ url = "https://files.pythonhosted.org/packages/38/6e/b4209711c61176acfeb6c351e9f88a37ed3d3bc3b749c374c0a655ee8f50/pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ec08a530bef8d3492d31d8b0b12d0cfde09539f2a1c4b9678662ebc3c843e478", size = 148133, upload-time = "2026-01-04T18:37:29.047Z" },
{ url = "https://files.pythonhosted.org/packages/26/28/f323b70789833baf0628af7b9f797b8c1a13b695bd8aa582b1312f14b602/pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d671168508129b472be570bc9aee361190ba917b997c703bd134bb4de445ce7", size = 147652, upload-time = "2026-01-04T18:37:30.682Z" },
{ url = "https://files.pythonhosted.org/packages/16/cd/9b0af0307a3a2cffb48ca76275c50b8bec3f85ca6e7b996e2e6cfbda1207/pyinstrument-5.1.2-cp310-cp310-win32.whl", hash = "sha256:5957a94f84564b374a7f856d1b322345d600964280b0d687b8ddcc483f21e576", size = 125793, upload-time = "2026-01-04T18:37:31.906Z" },
{ url = "https://files.pythonhosted.org/packages/05/89/fe4c650c252aefb8064bfdff6c0a020d33d15c55dc22abfa1f352dcc2dd1/pyinstrument-5.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:38a2180a7801c51610b50e5d423674b21872efd019ccf05a11b7f9016cb1dcfc", size = 126679, upload-time = "2026-01-04T18:37:33.59Z" },
{ url = "https://files.pythonhosted.org/packages/79/ef/0288edd620fb0cf2074d8c8e3567007a6bac66307b839d99988563de4eb8/pyinstrument-5.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3739a05583ea6312c385eb59fe985cd20d9048e95f9eeeb6a2f6c35202e2d36e", size = 131284, upload-time = "2026-01-04T18:37:35.01Z" },
{ url = "https://files.pythonhosted.org/packages/0b/4e/2a90a6997d9f7a39a6998d56de72e52673ebf5a9169a1c39dbf173e95105/pyinstrument-5.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c9ee05dc75ac5fb18498c311e624f77f7f321f7ff325b251aa09e52e46f1d6a", size = 124468, upload-time = "2026-01-04T18:37:36.628Z" },
{ url = "https://files.pythonhosted.org/packages/04/74/7bfd403e81f9b5ec523f60cced8f516ee52312752bb2e0fafabfd90bbd78/pyinstrument-5.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a49a55ca5b75218767e29cacbe515d0b66fc18cb48a937bca0f77b8dafc7202", size = 148057, upload-time = "2026-01-04T18:37:37.998Z" },
{ url = "https://files.pythonhosted.org/packages/50/3a/7205d7c199947d18edcd013af4ddf4d3cca85c5488fbe493050035947f7c/pyinstrument-5.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c45c14974ff04b1bfdc6c2a448627c6da7409c7800d0eb7bd03fb435dcb41d7", size = 146526, upload-time = "2026-01-04T18:37:39.642Z" },
{ url = "https://files.pythonhosted.org/packages/24/e8/f6864172e7ebe4bc5209bafbc574a619b4c511b9506b941789b11441be7c/pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22b9c04b3982c41c04b1c5ed05d1bc3a2ba26533450084058119f6dc160e70a3", size = 147179, upload-time = "2026-01-04T18:37:41.332Z" },
{ url = "https://files.pythonhosted.org/packages/6d/04/89ef2d1c34767bfdbcc74ab0c7e0d021d7fac5e79873239e4ca26e97d6da/pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c4995ee0774801790c138f0dfec17d4e7a7ef09a6d56d53cbcbf0578a711021", size = 146354, upload-time = "2026-01-04T18:37:42.808Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d4/64441547ec12391b92c739a3b0685059e7dfa088d928df8364676ef7abc7/pyinstrument-5.1.2-cp311-cp311-win32.whl", hash = "sha256:fe449e4a8ee60a2a27cf509350a584670f4c3704649601be7937598f09dbe7ca", size = 125790, upload-time = "2026-01-04T18:37:44.141Z" },
{ url = "https://files.pythonhosted.org/packages/4d/8b/0a5f6b239294decb0ecd932711f3470bfbd42fc2e08a94cd5c1f4f6da7f1/pyinstrument-5.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:3fb839429671a42bf349335af4c1ce5cf83386ac11f04df0bc40720d4cb7d77d", size = 126578, upload-time = "2026-01-04T18:37:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/26/d9/8fa5571ddd21b2b7189bd8b0bb4e90be1659a54dda5af51c7f6bf2b5666f/pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0", size = 131419, upload-time = "2026-01-04T18:37:46.843Z" },
{ url = "https://files.pythonhosted.org/packages/6f/50/0512adb83cadfeaa1a215dc9784defff5043c5aa052d15015e3d8013af75/pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5", size = 124446, upload-time = "2026-01-04T18:37:48.572Z" },
{ url = "https://files.pythonhosted.org/packages/9b/78/c45f0b668fb3c8c0d32058a451a8e1d34737cd7586387982185e12df1977/pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26", size = 149694, upload-time = "2026-01-04T18:37:49.876Z" },
{ url = "https://files.pythonhosted.org/packages/91/4d/2ca3ca9906ce6e05070f431c54d54ccbaf57a980cfa58032d35b0b0ac1f8/pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53", size = 148461, upload-time = "2026-01-04T18:37:51.544Z" },
{ url = "https://files.pythonhosted.org/packages/18/d2/bfe84a4326172ef68655b65b49fd041eeb94c8e59ee47258589b8b79dd3b/pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e", size = 148560, upload-time = "2026-01-04T18:37:52.931Z" },
{ url = "https://files.pythonhosted.org/packages/d0/00/db7f5def351e869230b0165828c4edacbf3fdda8d66aff30dd73a62082c2/pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d", size = 148178, upload-time = "2026-01-04T18:37:54.278Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bc/aea3329576e20b987d205027b8e6442ece845d681b9f9d8682d5404f81f3/pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281", size = 125927, upload-time = "2026-01-04T18:37:55.615Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/d928434ec3a840478e95fd0d73b0dfc0b8060a07b06f4b45e9df30444e9a/pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45", size = 126675, upload-time = "2026-01-04T18:37:57.278Z" },
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
@@ -2148,7 +2114,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2159,9 +2125,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@@ -2247,11 +2213,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.26"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]