From 7f3e8dd431791dd607873f8ab9207fd682cdd6c7 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 21 Sep 2026 21:12:02 +0100 Subject: [PATCH 01/13] feat: add fiat wallet to TPoS (phase 1) Model, services and migrations for booking cash settlement and fiat provider payments to the merchant's own fiat wallet: - `fiat_wallet_id` on `CreateTposData`/`Tpos` (never on `TposClean`) - `services_fiat.py`: canonical resolver (oldest non-deleted wallet in that currency) and idempotent find-or-create, so a user never gets a second fiat wallet per currency - `m027` adds the column; `m028` backfills cash/provider TPoS, reusing an existing wallet, disabling cash when no wallet can be assigned - `tests/test_fiat_wallet.py` covers backfill and resolution Not wired to payments yet: views_api/views_payments/tasks/UI follow. --- migrations.py | 81 ++++++++++++++++ models.py | 2 + services_fiat.py | 59 ++++++++++++ tests/test_fiat_wallet.py | 195 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 services_fiat.py create mode 100644 tests/test_fiat_wallet.py diff --git a/migrations.py b/migrations.py index 83e89ef..b12e14e 100644 --- a/migrations.py +++ b/migrations.py @@ -1,3 +1,5 @@ +from typing import Any + from lnbits.db import Database @@ -333,3 +335,82 @@ async def m026_add_onchain_payment_status(db: Database): await db.execute(""" UPDATE tpos.payments SET status = 'paid' WHERE paid = true; """) + + +async def m027_add_fiat_wallet(db: Database): + """ + Add the fiat wallet used by cash settlement and fiat provider payments. + """ + await db.execute(""" + ALTER TABLE tpos.pos ADD fiat_wallet_id TEXT NULL; + """) + + +async def m028_backfill_fiat_wallets(db: Database): + """ + Assign a fiat wallet to every TPoS that settles cash or fiat provider payments. + """ + # local imports: core modules are not importable while migrations load + from lnbits.core.db import db as core_db + from lnbits.utils.exchange_rates import allowed_currencies + from loguru import logger + + from .services_fiat import create_user_fiat_wallet + + allowed = allowed_currencies() + rows: list[Any] = await db.fetchall(""" + SELECT id, wallet, currency + FROM tpos.pos + WHERE fiat_wallet_id IS NULL + AND currency IS NOT NULL + AND UPPER(currency) <> 'SATS' + AND (allow_cash_settlement = true OR fiat_provider IS NOT NULL) + """) + async with core_db.connect() as core_conn: + for row in rows: + disable_cash = False + try: + currency = (row["currency"] or "").upper() + if currency not in allowed: + logger.warning( + f"tpos: {currency} is not a supported fiat currency, " + f"disabling cash settlement for TPoS {row['id']}" + ) + disable_cash = True + else: + owner = await core_conn.fetchone( + 'SELECT "user", deleted FROM wallets WHERE id = :id', + {"id": row["wallet"]}, + ) + if not owner or owner["deleted"]: + logger.warning( + f"tpos: TPoS {row['id']} has no owner wallet, " + "disabling cash settlement" + ) + disable_cash = True + else: + wallet = await create_user_fiat_wallet( + owner["user"], currency, conn=core_conn + ) + await db.execute( + """ + UPDATE tpos.pos SET fiat_wallet_id = :fiat_wallet_id + WHERE id = :id + """, + {"fiat_wallet_id": wallet.id, "id": row["id"]}, + ) + except Exception as exc: + logger.warning( + f"tpos: could not assign a fiat wallet to TPoS {row['id']}: {exc}" + ) + disable_cash = True + if disable_cash: + await db.execute( + "UPDATE tpos.pos SET allow_cash_settlement = false WHERE id = :id", + {"id": row["id"]}, + ) + + await db.execute(""" + UPDATE tpos.pos SET allow_cash_settlement = false + WHERE currency IS NULL OR UPPER(currency) = 'SATS'; + """) diff --git a/models.py b/models.py index 59f2fed..1be6e70 100644 --- a/models.py +++ b/models.py @@ -91,6 +91,7 @@ class CreateTposData(BaseModel): stripe_card_payments: bool = False stripe_reader_id: str | None = None allow_cash_settlement: bool = Field(False) + fiat_wallet_id: str | None = Field(None) onchain_enabled: bool = Field(False) onchain_wallet_id: str | None = None onchain_zero_conf: bool = Field(True) @@ -160,6 +161,7 @@ def can_withdraw(self) -> bool: class Tpos(TposClean, BaseModel): wallet: str tip_wallet: str | None = None + fiat_wallet_id: str | None = None class TposPaymentStatus(str, Enum): diff --git a/services_fiat.py b/services_fiat.py new file mode 100644 index 0000000..e65a75f --- /dev/null +++ b/services_fiat.py @@ -0,0 +1,59 @@ +from http import HTTPStatus + +from fastapi import HTTPException +from lnbits.core.crud.wallets import create_wallet, get_wallets +from lnbits.core.models.wallets import Wallet, WalletType +from lnbits.db import Connection +from loguru import logger + + +async def get_user_fiat_wallets( + user_id: str, conn: Connection | None = None +) -> list[Wallet]: + return await get_wallets(user_id, wallet_type=WalletType.FIAT, conn=conn) + + +async def find_fiat_wallet( + user_id: str, currency: str, conn: Connection | None = None +) -> Wallet | None: + """The user's fiat wallet in `currency`: the oldest non-deleted match.""" + currency = currency.upper() + wallets = [ + wallet + for wallet in await get_user_fiat_wallets(user_id, conn=conn) + if (wallet.currency or "").upper() == currency + ] + if not wallets: + return None + wallets.sort(key=lambda wallet: (wallet.created_at, wallet.id)) + if len(wallets) > 1: + logger.debug( + f"tpos: {len(wallets)} fiat wallets in {currency} for {user_id}, " + f"using {wallets[0].id}" + ) + return wallets[0] + + +async def create_user_fiat_wallet( + user_id: str, + currency: str, + name: str | None = None, + conn: Connection | None = None, +) -> Wallet: + """Find-or-create: idempotent, never a second wallet for a user + currency.""" + currency = currency.upper() + existing = await find_fiat_wallet(user_id, currency, conn=conn) + if existing: + return existing + try: + return await create_wallet( + user_id=user_id, + wallet_name=name or f"TPoS {currency}", + wallet_type=WalletType.FIAT, + currency=currency, + conn=conn, + ) + except ValueError as exc: + raise HTTPException( + HTTPStatus.BAD_REQUEST, f"Unsupported fiat currency {currency}." + ) from exc diff --git a/tests/test_fiat_wallet.py b/tests/test_fiat_wallet.py new file mode 100644 index 0000000..9ec0e25 --- /dev/null +++ b/tests/test_fiat_wallet.py @@ -0,0 +1,195 @@ +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from lnbits.core.crud.wallets import create_wallet, delete_wallet, update_wallet +from lnbits.core.models.users import Account +from lnbits.core.models.wallets import WalletType +from lnbits.core.services.users import create_user_account_no_ckeck +from lnbits.db import Connection + +from tpos.crud import db # type: ignore[import] +from tpos.migrations import m028_backfill_fiat_wallets # type: ignore[import] +from tpos.services_fiat import ( # type: ignore[import] + create_user_fiat_wallet, + find_fiat_wallet, + get_user_fiat_wallets, +) + + +async def _user(username: str): + account = Account(id=uuid4().hex, username=username) + user = await create_user_account_no_ckeck(account=account) + return user, user.wallets[0] + + +async def _add_tpos( + conn: Connection, + *, + tpos_id: str, + wallet: str, + currency: str, + cash: bool = False, + provider: str | None = None, +) -> None: + await conn.execute( + """ + INSERT INTO tpos.pos + (id, wallet, name, currency, allow_cash_settlement, fiat_provider) + VALUES (:id, :wallet, :name, :currency, :cash, :provider) + """, + { + "id": tpos_id, + "wallet": wallet, + "name": tpos_id, + "currency": currency, + "cash": cash, + "provider": provider, + }, + ) + + +async def _tpos_row(conn: Connection, tpos_id: str) -> dict: + row: Any = await conn.fetchone( + """ + SELECT fiat_wallet_id, allow_cash_settlement, fiat_provider + FROM tpos.pos WHERE id = :id + """, + {"id": tpos_id}, + ) + return dict(row) + + +@pytest.mark.asyncio +async def test_backfill_shares_one_fiat_wallet(): + user, wallet = await _user("fiat_shared") + async with db.connect() as conn: + await _add_tpos( + conn, tpos_id="cash-pos", wallet=wallet.id, currency="EUR", cash=True + ) + await _add_tpos( + conn, + tpos_id="card-pos", + wallet=wallet.id, + currency="EUR", + provider="stripe", + ) + await m028_backfill_fiat_wallets(conn) + cash_row = await _tpos_row(conn, "cash-pos") + card_row = await _tpos_row(conn, "card-pos") + + assert cash_row["fiat_wallet_id"] + assert cash_row["fiat_wallet_id"] == card_row["fiat_wallet_id"] + + fiat_wallets = await get_user_fiat_wallets(user.id) + assert [fiat.id for fiat in fiat_wallets] == [cash_row["fiat_wallet_id"]] + assert fiat_wallets[0].wallet_type == WalletType.FIAT.value + assert fiat_wallets[0].currency == "EUR" + assert fiat_wallets[0].user == user.id + + +@pytest.mark.asyncio +async def test_backfill_is_idempotent(): + user, wallet = await _user("fiat_idempotent") + async with db.connect() as conn: + await _add_tpos( + conn, tpos_id="cash-pos", wallet=wallet.id, currency="EUR", cash=True + ) + await m028_backfill_fiat_wallets(conn) + first = await _tpos_row(conn, "cash-pos") + await m028_backfill_fiat_wallets(conn) + second = await _tpos_row(conn, "cash-pos") + + assert first["fiat_wallet_id"] == second["fiat_wallet_id"] + assert len(await get_user_fiat_wallets(user.id)) == 1 + + +@pytest.mark.asyncio +async def test_backfill_reuses_an_existing_fiat_wallet(): + user, wallet = await _user("fiat_reuse") + existing = await create_wallet( + user_id=user.id, wallet_type=WalletType.FIAT, currency="EUR" + ) + async with db.connect() as conn: + await _add_tpos( + conn, tpos_id="cash-pos", wallet=wallet.id, currency="EUR", cash=True + ) + await m028_backfill_fiat_wallets(conn) + row = await _tpos_row(conn, "cash-pos") + + assert row["fiat_wallet_id"] == existing.id + assert len(await get_user_fiat_wallets(user.id)) == 1 + + +@pytest.mark.asyncio +async def test_backfill_disables_cash_when_no_wallet_can_be_assigned(): + user, wallet = await _user("fiat_disabled") + await delete_wallet(user.id, wallet.id) + async with db.connect() as conn: + await _add_tpos( + conn, tpos_id="sats-pos", wallet=wallet.id, currency="sats", cash=True + ) + await _add_tpos( + conn, + tpos_id="unsupported-pos", + wallet=wallet.id, + currency="XYZ", + cash=True, + provider="stripe", + ) + await _add_tpos( + conn, tpos_id="orphan-pos", wallet=wallet.id, currency="USD", cash=True + ) + await m028_backfill_fiat_wallets(conn) + sats_row = await _tpos_row(conn, "sats-pos") + unsupported_row = await _tpos_row(conn, "unsupported-pos") + orphan_row = await _tpos_row(conn, "orphan-pos") + + assert sats_row["allow_cash_settlement"] in (False, 0) + assert sats_row["fiat_wallet_id"] is None + assert unsupported_row["allow_cash_settlement"] in (False, 0) + assert unsupported_row["fiat_provider"] == "stripe" + assert unsupported_row["fiat_wallet_id"] is None + assert orphan_row["allow_cash_settlement"] in (False, 0) + assert orphan_row["fiat_wallet_id"] is None + assert await get_user_fiat_wallets(user.id) == [] + + +@pytest.mark.asyncio +async def test_find_fiat_wallet_returns_the_oldest_match(): + user, _ = await _user("fiat_canonical") + older = await create_wallet( + user_id=user.id, wallet_type=WalletType.FIAT, currency="EUR" + ) + older.created_at = datetime.now(timezone.utc) - timedelta(days=1) + await update_wallet(older) + await create_wallet(user_id=user.id, wallet_type=WalletType.FIAT, currency="EUR") + await create_wallet(user_id=user.id, wallet_type=WalletType.FIAT, currency="USD") + + assert (await find_fiat_wallet(user.id, "eur")).id == older.id + assert await find_fiat_wallet(user.id, "XYZ") is None + + +@pytest.mark.asyncio +async def test_create_user_fiat_wallet_is_idempotent(): + user, _ = await _user("fiat_create") + wallet = await create_user_fiat_wallet(user.id, "eur") + again = await create_user_fiat_wallet(user.id, "EUR", name="Other name") + + assert wallet.id == again.id + assert wallet.name == "TPoS EUR" + assert wallet.wallet_type == WalletType.FIAT.value + assert wallet.currency == "EUR" + assert len(await get_user_fiat_wallets(user.id)) == 1 + + +@pytest.mark.asyncio +async def test_create_user_fiat_wallet_rejects_unsupported_currency(): + user, _ = await _user("fiat_bad_currency") + with pytest.raises(HTTPException) as exc: + await create_user_fiat_wallet(user.id, "XYZ") + + assert exc.value.status_code == 400 + assert await get_user_fiat_wallets(user.id) == [] From 10fb1db4ffc64093a657e187a15432c8adf334e7 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 21 Sep 2026 21:15:07 +0100 Subject: [PATCH 02/13] chore: require lnbits 1.6.2 for fiat wallets Bumps the extension to 1.2.0 and gates installs on the LNbits release that ships fiat wallets and the fiat wallet currency argument. --- config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.json b/config.json index 0d2a786..554cc12 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,12 @@ { "id": "tpos", - "version": "1.1.2", + "version": "1.2.0", "name": "TPoS", "repo": "https://github.com/lnbits/tpos", "short_description": "A shareable PoS terminal!", "description": "", "tile": "/tpos/static/image/tpos.png", - "min_lnbits_version": "1.5.0", + "min_lnbits_version": "1.6.2", "contributors": [ { "name": "Ben Arc", From 169b75937cf4e316938b5a2879116c2f29639e7e Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 21 Sep 2026 21:23:08 +0100 Subject: [PATCH 03/13] feat: resolve the TPoS fiat wallet on save (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services_fiat: `resolve_tpos_fiat_wallet` implements R1-R12 — cash settlement is not gated, but setting/changing a `fiat_provider` requires the admin to have enabled card payments for the merchant (hard 400, checked before any wallet lookup); `get_valid_tpos_fiat_wallet` guards deleted/foreign/stale assignments for the payment path - views_api: refuse a fiat wallet as the TPoS wallet, drop the cash superuser gates, resolve the wallet on create and update - new endpoints: `GET /api/v1/wallets` (split wallets, no keys) and `POST /api/v1/fiat/wallets` (idempotent, 200 existing / 201 created) - tests: 7 API tests for the rules above; test helpers moved to the core >= 1.6 internal invoice queue and gained a shared `enable_stripe` fixture --- models.py | 20 +++ services_fiat.py | 97 ++++++++++++++- tests/conftest.py | 14 +++ tests/test_api.py | 7 +- tests/test_fiat_wallet.py | 247 ++++++++++++++++++++++++++++++++++++++ tests/test_tabs.py | 4 +- views_api.py | 93 ++++++++++++-- 7 files changed, 467 insertions(+), 15 deletions(-) diff --git a/models.py b/models.py index 1be6e70..efc0493 100644 --- a/models.py +++ b/models.py @@ -164,6 +164,26 @@ class Tpos(TposClean, BaseModel): fiat_wallet_id: str | None = None +class TposWalletOption(BaseModel): + """Wallet data for the admin UI — never carries keys.""" + + id: str + name: str + currency: str | None = None + balance_msat: int = 0 + + +class TposWallets(BaseModel): + can_create_fiat_wallet: bool + lightning_wallets: list[TposWalletOption] = Field(default_factory=list) + fiat_wallets: list[TposWalletOption] = Field(default_factory=list) + + +class CreateFiatWalletData(BaseModel): + currency: str = Field(..., min_length=3, max_length=3) + name: str | None = Field(None, max_length=64) + + class TposPaymentStatus(str, Enum): PENDING = "pending" PAID = "paid" diff --git a/services_fiat.py b/services_fiat.py index e65a75f..747b2f1 100644 --- a/services_fiat.py +++ b/services_fiat.py @@ -1,11 +1,14 @@ from http import HTTPStatus from fastapi import HTTPException -from lnbits.core.crud.wallets import create_wallet, get_wallets +from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets from lnbits.core.models.wallets import Wallet, WalletType from lnbits.db import Connection +from lnbits.settings import settings from loguru import logger +from .models import Tpos + async def get_user_fiat_wallets( user_id: str, conn: Connection | None = None @@ -57,3 +60,95 @@ async def create_user_fiat_wallet( raise HTTPException( HTTPStatus.BAD_REQUEST, f"Unsupported fiat currency {currency}." ) from exc + + +async def resolve_tpos_fiat_wallet( + *, + user_id: str, + currency: str | None, + cash_settlement: bool, + fiat_provider: str | None, + requested_id: str | None, + provider_changed: bool = True, +) -> str | None: + """The fiat wallet a TPoS must settle to, or `None` when it needs none (R1-R12).""" + currency = (currency or "").upper() + if currency in ("", "SATS") or not (cash_settlement or fiat_provider): + return None + + # Card payments must be enabled for the merchant by the admin, even when the + # merchant already owns a fiat wallet (R7c). + if ( + fiat_provider + and provider_changed + and fiat_provider not in settings.get_fiat_providers_for_user(user_id) + ): + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Card payments are not enabled for you. " + "Ask your admin to enable fiat payments.", + ) + + if requested_id: + return (await _validate_requested_wallet(requested_id, user_id, currency)).id + + wallet = await find_fiat_wallet(user_id, currency) + if wallet: + return wallet.id + + try: + wallet = await create_user_fiat_wallet(user_id, currency) + except HTTPException as exc: + # R7b: an unchanged legacy provider TPoS keeps working on the lightning wallet. + if fiat_provider and not cash_settlement and not provider_changed: + logger.warning( + f"tpos: no fiat wallet in {currency} for {user_id}, " + "provider payments stay on the lightning wallet" + ) + return None + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"Cash settlement needs a fiat wallet in {currency}." + if cash_settlement + else f"Card payments need a fiat wallet in {currency}. " + "Ask your admin to enable fiat payments." + ), + ) from exc + return wallet.id + + +async def _validate_requested_wallet( + wallet_id: str, user_id: str, currency: str +) -> Wallet: + wallet = await get_wallet(wallet_id) + if not wallet: + raise HTTPException(HTTPStatus.BAD_REQUEST, "Fiat wallet not found.") + if wallet.wallet_type != WalletType.FIAT.value: + raise HTTPException( + HTTPStatus.BAD_REQUEST, f"{wallet.name} is not a fiat wallet." + ) + if wallet.user != user_id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Fiat wallet does not belong to you.") + if (wallet.currency or "").upper() != currency: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Fiat wallet currency {wallet.currency} does not match " + f"TPoS currency {currency}.", + ) + return wallet + + +async def get_valid_tpos_fiat_wallet(tpos: Tpos) -> Wallet | None: + """The TPoS' fiat wallet, or `None` when it is gone, foreign or stale.""" + if not tpos.fiat_wallet_id: + return None + wallet = await get_wallet(tpos.fiat_wallet_id) + if not wallet or wallet.wallet_type != WalletType.FIAT.value: + return None + if (wallet.currency or "").upper() != (tpos.currency or "").upper(): + return None + owner = await get_wallet(tpos.wallet) + if not owner or owner.user != wallet.user: + return None + return wallet diff --git a/tests/conftest.py b/tests/conftest.py index 832d3cf..4283660 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from typing import Any, cast import httpx +import pytest import pytest_asyncio import tabs.migrations as tabs_migrations # type: ignore[import] from fastapi import FastAPI @@ -63,3 +64,16 @@ def app_client(*args, **kwargs): monkeypatch.setattr(httpx, "AsyncClient", app_client) async with AsyncClient(transport=transport, base_url="http://testserver") as client: yield client + + +@pytest.fixture +def enable_stripe(): + """Enable the fiat provider as an admin would (`allowed_users=[]` = everyone).""" + + def _enable(user_id: str | None = None): + settings.stripe_enabled = True + settings.stripe_limits.allowed_users = [user_id] if user_id else [] + + yield _enable + settings.stripe_enabled = False + settings.stripe_limits.allowed_users = [] diff --git a/tests/test_api.py b/tests/test_api.py index 1a9795f..837523f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ ) from lnbits.core.services.users import create_user_account_no_ckeck from lnbits.settings import settings -from lnbits.tasks import internal_invoice_queue +from lnbits.task_manager import task_manager from tabs.crud import ( # type: ignore[import] get_tab_by_id, get_tab_entries, @@ -76,7 +76,7 @@ async def _user_with_tabs(username: str = "tposuser"): async def _drain_internal_invoice_queue() -> None: while True: try: - internal_invoice_queue.get_nowait() + task_manager.internal_invoice_queue.get_nowait() except asyncio.QueueEmpty: return @@ -492,9 +492,10 @@ async def fake_websocket_updater(channel, message): @pytest.mark.asyncio async def test_terminal_invoice_keeps_reader_and_tap_to_pay_payload( - client: AsyncClient, monkeypatch + client: AsyncClient, monkeypatch, enable_stripe ): _user, wallet = await _user_with_tabs("terminaluser") + enable_stripe(_user.id) headers = {"X-API-KEY": wallet.adminkey} create = await client.post( "/tpos/api/v1/tposs", diff --git a/tests/test_fiat_wallet.py b/tests/test_fiat_wallet.py index 9ec0e25..418cd34 100644 --- a/tests/test_fiat_wallet.py +++ b/tests/test_fiat_wallet.py @@ -4,6 +4,7 @@ import pytest from fastapi import HTTPException +from httpx import AsyncClient from lnbits.core.crud.wallets import create_wallet, delete_wallet, update_wallet from lnbits.core.models.users import Account from lnbits.core.models.wallets import WalletType @@ -25,6 +26,26 @@ async def _user(username: str): return user, user.wallets[0] +def _tpos_payload(**overrides) -> dict: + payload = { + "wallet": None, + "name": "Fiat TPoS", + "currency": "EUR", + "business_name": "Fiat Shop", + "business_address": "1 Market Street", + "business_vat_id": "VAT123", + "tip_options": "[]", + "tip_wallet": "", + "withdraw_between": 1, + "withdraw_limit": 100, + "withdraw_time_option": "secs", + "enable_receipt_print": True, + "enable_remote": True, + } + payload.update(overrides) + return payload + + async def _add_tpos( conn: Connection, *, @@ -193,3 +214,229 @@ async def test_create_user_fiat_wallet_rejects_unsupported_currency(): assert exc.value.status_code == 400 assert await get_user_fiat_wallets(user.id) == [] + + +@pytest.mark.asyncio +async def test_cash_settlement_needs_no_superuser(client: AsyncClient): + user, wallet = await _user("fiat_cash_api") + assert not user.super_user + + response = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(allow_cash_settlement=True), + headers={"X-API-KEY": wallet.adminkey}, + ) + + assert response.status_code == 201, response.text + tpos = response.json() + assert tpos["fiat_wallet_id"] + fiat_wallet = await find_fiat_wallet(user.id, "EUR") + assert fiat_wallet and fiat_wallet.id == tpos["fiat_wallet_id"] + assert fiat_wallet.currency == "EUR" + assert fiat_wallet.user == user.id + + +@pytest.mark.asyncio +async def test_tpos_requires_a_lightning_wallet(client: AsyncClient): + user, _ = await _user("fiat_key_rejected") + fiat_wallet = await create_user_fiat_wallet(user.id, "EUR") + + response = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(), + headers={"X-API-KEY": fiat_wallet.adminkey}, + ) + + assert response.status_code == 400 + assert "Lightning wallet" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_card_payments_require_admin_enablement( + client: AsyncClient, enable_stripe +): + user, wallet = await _user("fiat_card_api") + headers = {"X-API-KEY": wallet.adminkey} + payload = _tpos_payload(fiat_provider="stripe") + + refused = await client.post("/tpos/api/v1/tposs", json=payload, headers=headers) + assert refused.status_code == 400 + assert "Card payments are not enabled" in refused.json()["detail"] + + enable_stripe(user.id) + allowed = await client.post("/tpos/api/v1/tposs", json=payload, headers=headers) + + assert allowed.status_code == 201, allowed.text + fiat_wallet = await find_fiat_wallet(user.id, "EUR") + assert allowed.json()["fiat_wallet_id"] == (fiat_wallet and fiat_wallet.id) + + +@pytest.mark.asyncio +async def test_explicit_fiat_wallet_is_validated(client: AsyncClient): + user, wallet = await _user("fiat_explicit") + other_user, _ = await _user("fiat_explicit_other") + own = await create_user_fiat_wallet(user.id, "EUR") + other = await create_user_fiat_wallet(other_user.id, "EUR") + usd = await create_user_fiat_wallet(user.id, "USD") + headers = {"X-API-KEY": wallet.adminkey} + + accepted = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload( + name="Explicit", allow_cash_settlement=True, fiat_wallet_id=own.id + ), + headers=headers, + ) + assert accepted.status_code == 201, accepted.text + assert accepted.json()["fiat_wallet_id"] == own.id + + cases = { + other.id: 403, + wallet.id: 400, + usd.id: 400, + "does-not-exist": 400, + } + for wallet_id, status in cases.items(): + response = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload( + name=f"Bad {wallet_id}", + allow_cash_settlement=True, + fiat_wallet_id=wallet_id, + ), + headers=headers, + ) + assert response.status_code == status, response.text + + +@pytest.mark.asyncio +async def test_update_reresolves_the_fiat_wallet(client: AsyncClient): + user, wallet = await _user("fiat_update") + headers = {"X-API-KEY": wallet.adminkey} + created = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(allow_cash_settlement=True), + headers=headers, + ) + tpos_id = created.json()["id"] + eur_wallet_id = created.json()["fiat_wallet_id"] + + usd = await client.put( + f"/tpos/api/v1/tposs/{tpos_id}", + json=_tpos_payload(currency="USD", allow_cash_settlement=True), + headers=headers, + ) + assert usd.status_code == 200, usd.text + usd_wallet_id = usd.json()["fiat_wallet_id"] + assert usd_wallet_id and usd_wallet_id != eur_wallet_id + + sats = await client.put( + f"/tpos/api/v1/tposs/{tpos_id}", + json=_tpos_payload(currency="sats", allow_cash_settlement=True), + headers=headers, + ) + assert sats.status_code == 200, sats.text + assert sats.json()["fiat_wallet_id"] is None + assert sats.json()["allow_cash_settlement"] is False + + back_to_eur = await client.put( + f"/tpos/api/v1/tposs/{tpos_id}", + json=_tpos_payload(currency="EUR", allow_cash_settlement=True), + headers=headers, + ) + assert back_to_eur.json()["fiat_wallet_id"] == eur_wallet_id + + released = await client.put( + f"/tpos/api/v1/tposs/{tpos_id}", + json=_tpos_payload(currency="EUR", allow_cash_settlement=False), + headers=headers, + ) + assert released.json()["fiat_wallet_id"] is None + assert len(await get_user_fiat_wallets(user.id)) == 2 + + +@pytest.mark.asyncio +async def test_wallet_endpoints_share_and_never_leak_keys(client: AsyncClient): + user, wallet = await _user("fiat_wallets_api") + headers = {"X-API-KEY": wallet.adminkey} + for name in ("First", "Second"): + created = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(name=name, allow_cash_settlement=True), + headers=headers, + ) + assert created.status_code == 201, created.text + + tposs = await client.get("/tpos/api/v1/tposs", headers=headers) + wallet_ids = {tpos["fiat_wallet_id"] for tpos in tposs.json()} + assert len(wallet_ids) == 1 and None not in wallet_ids + + existing = await client.post( + "/tpos/api/v1/fiat/wallets", json={"currency": "eur"}, headers=headers + ) + assert existing.status_code == 200, existing.text + assert existing.json()["id"] in wallet_ids + assert len(await get_user_fiat_wallets(user.id)) == 1 + + new_wallet = await client.post( + "/tpos/api/v1/fiat/wallets", json={"currency": "USD"}, headers=headers + ) + assert new_wallet.status_code == 201, new_wallet.text + assert new_wallet.json()["currency"] == "USD" + + bad_currency = await client.post( + "/tpos/api/v1/fiat/wallets", json={"currency": "XYZ"}, headers=headers + ) + assert bad_currency.status_code == 400 + + status = await client.get( + "/tpos/api/v1/wallets", headers={"X-API-KEY": wallet.inkey} + ) + assert status.status_code == 200, status.text + status_json = status.json() + assert status_json["can_create_fiat_wallet"] is False + assert [item["id"] for item in status_json["lightning_wallets"]] == [wallet.id] + assert {item["currency"] for item in status_json["fiat_wallets"]} == {"EUR", "USD"} + assert "adminkey" not in status.text and "inkey" not in status.text + + +@pytest.mark.asyncio +async def test_legacy_provider_tpos_is_tolerated_until_it_changes( + client: AsyncClient, enable_stripe +): + user, wallet = await _user("fiat_legacy_provider") + enable_stripe(user.id) + headers = {"X-API-KEY": wallet.adminkey} + created = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(name="Legacy", fiat_provider="stripe"), + headers=headers, + ) + assert created.status_code == 201, created.text + tpos_id = created.json()["id"] + # un-backfillable legacy state: provider kept, no fiat wallet assigned + async with db.connect() as conn: + await conn.execute( + """ + UPDATE tpos.pos + SET currency = 'XYZ', fiat_wallet_id = NULL + WHERE id = :id + """, + {"id": tpos_id}, + ) + + tolerated = await client.put( + f"/tpos/api/v1/tposs/{tpos_id}", + json=_tpos_payload(name="Renamed", currency="XYZ", fiat_provider="stripe"), + headers=headers, + ) + assert tolerated.status_code == 200, tolerated.text + assert tolerated.json()["fiat_wallet_id"] is None + + refused = await client.put( + f"/tpos/api/v1/tposs/{tpos_id}", + json=_tpos_payload(name="Renamed", currency="XYZ", fiat_provider="paypal"), + headers=headers, + ) + assert refused.status_code == 400 + assert "Card payments are not enabled" in refused.json()["detail"] diff --git a/tests/test_tabs.py b/tests/test_tabs.py index f5b83ed..fdcfe0a 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -5,7 +5,7 @@ from lnbits.core.crud import get_standalone_payment from lnbits.core.services import pay_invoice, update_wallet_balance from lnbits.core.services.users import create_user_account_no_ckeck -from lnbits.tasks import internal_invoice_queue +from lnbits.task_manager import task_manager from tpos.crud import get_tpos_payment_by_hash # type: ignore[import] from tpos.tasks import on_invoice_paid # type: ignore[import] @@ -14,7 +14,7 @@ async def _drain_internal_invoice_queue() -> None: while True: try: - internal_invoice_queue.get_nowait() + task_manager.internal_invoice_queue.get_nowait() except asyncio.QueueEmpty: return diff --git a/views_api.py b/views_api.py index c8334d4..f7fb4c0 100644 --- a/views_api.py +++ b/views_api.py @@ -1,15 +1,18 @@ import json from http import HTTPStatus -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from lnbits.core.crud import ( get_user, ) +from lnbits.core.crud.wallets import get_wallets from lnbits.core.models import WalletTypeInfo +from lnbits.core.models.wallets import Wallet, WalletType from lnbits.decorators import ( require_admin_key, require_invoice_key, ) +from lnbits.settings import settings from lnurl import LnurlPayResponse from lnurl import handle as lnurl_handle @@ -25,9 +28,17 @@ inventory_tags_to_string, ) from .models import ( + CreateFiatWalletData, CreateTposData, CreateUpdateItemData, Tpos, + TposWalletOption, + TposWallets, +) +from .services_fiat import ( + create_user_fiat_wallet, + find_fiat_wallet, + resolve_tpos_fiat_wallet, ) from .services_inventory import ( get_default_inventory, @@ -65,6 +76,11 @@ async def api_tposs( async def api_tpos_create( data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key) ): + if wallet.wallet.wallet_type == WalletType.FIAT.value: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A TPoS requires a Lightning wallet.", + ) data.wallet = wallet.wallet.id await _validate_watchonly_settings( wallet=wallet.wallet, @@ -74,10 +90,9 @@ async def api_tpos_create( if not data.tabs_enabled: data.tabs_allow_create = False user = await get_user(wallet.wallet.user) - if not (user and user.super_user): - data.allow_cash_settlement = False if data.currency == "sats": data.allow_cash_settlement = False + data.fiat_provider = None if data.use_inventory and not inventory_available_for_user(user): data.use_inventory = False if data.use_inventory and not data.inventory_id: @@ -88,6 +103,13 @@ async def api_tpos_create( data.inventory_id = inventory.get("id") data.inventory_tags = inventory.get("tags") data.inventory_omit_tags = inventory.get("omit_tags") + data.fiat_wallet_id = await resolve_tpos_fiat_wallet( + user_id=wallet.wallet.user, + currency=data.currency, + cash_settlement=data.allow_cash_settlement, + fiat_provider=data.fiat_provider, + requested_id=data.fiat_wallet_id, + ) tpos = await create_tpos(data) return tpos @@ -125,12 +147,20 @@ async def api_tpos_update( desired_currency = update_payload.get("currency", tpos.currency) if desired_currency == "sats": update_payload["allow_cash_settlement"] = False - if "allow_cash_settlement" in update_payload: - if update_payload["allow_cash_settlement"] and not (user and user.super_user): - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Cash settlement can only be enabled by super users.", - ) + update_payload["fiat_provider"] = None + update_payload["fiat_wallet_id"] = await resolve_tpos_fiat_wallet( + user_id=wallet.wallet.user, + currency=desired_currency, + cash_settlement=update_payload.get( + "allow_cash_settlement", tpos.allow_cash_settlement + ), + fiat_provider=update_payload.get("fiat_provider", tpos.fiat_provider), + requested_id=update_payload.get("fiat_wallet_id"), + provider_changed=( + "fiat_provider" in update_payload + and update_payload["fiat_provider"] != tpos.fiat_provider + ), + ) if update_payload.get("use_inventory") and not update_payload.get("inventory_id"): inventory = await get_default_inventory(wallet.wallet.user) if inventory: @@ -159,6 +189,51 @@ async def api_tpos_update( return tpos +def _wallet_option(wallet: Wallet) -> TposWalletOption: + return TposWalletOption( + id=wallet.id, + name=wallet.name, + currency=wallet.currency, + balance_msat=wallet.balance_msat, + ) + + +@tpos_api_router.get("/api/v1/wallets", status_code=HTTPStatus.OK) +async def api_tpos_wallets( + key_info: WalletTypeInfo = Depends(require_invoice_key), +) -> TposWallets: + user_id = key_info.wallet.user + lightning_wallets: list[TposWalletOption] = [] + fiat_wallets: list[TposWalletOption] = [] + for wallet in await get_wallets(user_id): + if wallet.wallet_type == WalletType.FIAT.value: + fiat_wallets.append(_wallet_option(wallet)) + elif wallet.can_receive_payments: + lightning_wallets.append(_wallet_option(wallet)) + return TposWallets( + can_create_fiat_wallet=settings.can_create_fiat_wallet(user_id), + lightning_wallets=lightning_wallets, + fiat_wallets=fiat_wallets, + ) + + +@tpos_api_router.post("/api/v1/fiat/wallets", status_code=HTTPStatus.CREATED) +async def api_tpos_create_fiat_wallet( + data: CreateFiatWalletData, + response: Response, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> TposWalletOption: + currency = data.currency.upper() + existing = await find_fiat_wallet(wallet.wallet.user, currency) + if existing: + response.status_code = HTTPStatus.OK + return _wallet_option(existing) + created = await create_user_fiat_wallet( + wallet.wallet.user, currency, name=data.name + ) + return _wallet_option(created) + + @tpos_api_router.delete("/api/v1/tposs/{tpos_id}") async def api_tpos_delete( tpos_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) From 6abb0c3dfb09221e9490117ee9c16a9237c26e49 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 22 Sep 2026 14:20:41 +0100 Subject: [PATCH 04/13] chore: tighten the fiat wallet migration and tests (phase 2 review) --- migrations.py | 44 ++++++++++++++++----------------------- tests/conftest.py | 12 +++++------ tests/test_fiat_wallet.py | 26 ++++++++++++++++++++++- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/migrations.py b/migrations.py index b12e14e..051cb4a 100644 --- a/migrations.py +++ b/migrations.py @@ -352,12 +352,10 @@ async def m028_backfill_fiat_wallets(db: Database): """ # local imports: core modules are not importable while migrations load from lnbits.core.db import db as core_db - from lnbits.utils.exchange_rates import allowed_currencies from loguru import logger from .services_fiat import create_user_fiat_wallet - allowed = allowed_currencies() rows: list[Any] = await db.fetchall(""" SELECT id, wallet, currency FROM tpos.pos @@ -370,35 +368,29 @@ async def m028_backfill_fiat_wallets(db: Database): for row in rows: disable_cash = False try: - currency = (row["currency"] or "").upper() - if currency not in allowed: + owner = await core_conn.fetchone( + 'SELECT "user", deleted FROM wallets WHERE id = :id', + {"id": row["wallet"]}, + ) + if not owner or owner["deleted"]: logger.warning( - f"tpos: {currency} is not a supported fiat currency, " - f"disabling cash settlement for TPoS {row['id']}" + f"tpos: TPoS {row['id']} has no owner wallet, " + "disabling cash settlement" ) disable_cash = True else: - owner = await core_conn.fetchone( - 'SELECT "user", deleted FROM wallets WHERE id = :id', - {"id": row["wallet"]}, + wallet = await create_user_fiat_wallet( + owner["user"], + (row["currency"] or "").upper(), + conn=core_conn, + ) + await db.execute( + """ + UPDATE tpos.pos SET fiat_wallet_id = :fiat_wallet_id + WHERE id = :id + """, + {"fiat_wallet_id": wallet.id, "id": row["id"]}, ) - if not owner or owner["deleted"]: - logger.warning( - f"tpos: TPoS {row['id']} has no owner wallet, " - "disabling cash settlement" - ) - disable_cash = True - else: - wallet = await create_user_fiat_wallet( - owner["user"], currency, conn=core_conn - ) - await db.execute( - """ - UPDATE tpos.pos SET fiat_wallet_id = :fiat_wallet_id - WHERE id = :id - """, - {"fiat_wallet_id": wallet.id, "id": row["id"]}, - ) except Exception as exc: logger.warning( f"tpos: could not assign a fiat wallet to TPoS {row['id']}: {exc}" diff --git a/tests/conftest.py b/tests/conftest.py index 4283660..cd06ba3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,13 +67,13 @@ def app_client(*args, **kwargs): @pytest.fixture -def enable_stripe(): +def enable_stripe(monkeypatch): """Enable the fiat provider as an admin would (`allowed_users=[]` = everyone).""" def _enable(user_id: str | None = None): - settings.stripe_enabled = True - settings.stripe_limits.allowed_users = [user_id] if user_id else [] + monkeypatch.setattr(settings, "stripe_enabled", True) + monkeypatch.setattr( + settings.stripe_limits, "allowed_users", [user_id] if user_id else [] + ) - yield _enable - settings.stripe_enabled = False - settings.stripe_limits.allowed_users = [] + return _enable diff --git a/tests/test_fiat_wallet.py b/tests/test_fiat_wallet.py index 418cd34..c24d464 100644 --- a/tests/test_fiat_wallet.py +++ b/tests/test_fiat_wallet.py @@ -178,6 +178,27 @@ async def test_backfill_disables_cash_when_no_wallet_can_be_assigned(): assert await get_user_fiat_wallets(user.id) == [] +@pytest.mark.asyncio +async def test_backfill_disables_cash_for_an_unsupported_currency(): + user, wallet = await _user("fiat_unsupported") + async with db.connect() as conn: + await _add_tpos( + conn, + tpos_id="unsupported-pos", + wallet=wallet.id, + currency="XYZ", + cash=True, + provider="stripe", + ) + await m028_backfill_fiat_wallets(conn) + row = await _tpos_row(conn, "unsupported-pos") + + assert row["allow_cash_settlement"] in (False, 0) + assert row["fiat_provider"] == "stripe" + assert row["fiat_wallet_id"] is None + assert await get_user_fiat_wallets(user.id) == [] + + @pytest.mark.asyncio async def test_find_fiat_wallet_returns_the_oldest_match(): user, _ = await _user("fiat_canonical") @@ -397,7 +418,10 @@ async def test_wallet_endpoints_share_and_never_leak_keys(client: AsyncClient): assert status_json["can_create_fiat_wallet"] is False assert [item["id"] for item in status_json["lightning_wallets"]] == [wallet.id] assert {item["currency"] for item in status_json["fiat_wallets"]} == {"EUR", "USD"} - assert "adminkey" not in status.text and "inkey" not in status.text + assert all( + set(item) == {"id", "name", "currency", "balance_msat"} + for item in status_json["lightning_wallets"] + status_json["fiat_wallets"] + ) @pytest.mark.asyncio From 108b6349a6613d2e95f8a4fc520b9b55d10a0f28 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 22 Sep 2026 14:23:30 +0100 Subject: [PATCH 05/13] feat: route fiat payments to the TPoS fiat wallet (phase 3) --- tasks.py | 61 ++++++--- tests/test_api.py | 6 +- tests/test_fiat_wallet.py | 282 +++++++++++++++++++++++++++++++++++++- views_payments.py | 64 +++++---- 4 files changed, 365 insertions(+), 48 deletions(-) diff --git a/tasks.py b/tasks.py index 64eb13f..13e624a 100644 --- a/tasks.py +++ b/tasks.py @@ -129,18 +129,25 @@ async def on_invoice_paid(payment: Payment) -> None: ): return - payment_method = payment.extra.get("payment_method") or _payment_method(payment) - tpos_payment = await get_tpos_payment_by_hash(payment.payment_hash) - if tpos_payment and not tpos_payment.paid: - tpos_payment.paid = True - tpos_payment.status = TposPaymentStatus.PAID - tpos_payment.payment_method = payment_method - await update_tpos_payment(tpos_payment) - - if payment.extra.get("tpos_processed"): - return - - await process_paid_tpos_payment(payment, payment_method=payment_method) + try: + payment_method = payment.extra.get("payment_method") or _payment_method(payment) + tpos_payment = await get_tpos_payment_by_hash(payment.payment_hash) + if tpos_payment and not tpos_payment.paid: + tpos_payment.paid = True + tpos_payment.status = TposPaymentStatus.PAID + tpos_payment.payment_method = payment_method + await update_tpos_payment(tpos_payment) + + if payment.extra.get("tpos_processed"): + return + + await process_paid_tpos_payment(payment, payment_method=payment_method) + except Exception as exc: + # one bad payment must never stop the invoice listener + logger.error( + f"tpos: failed to process payment {payment.payment_hash}: " + f"{type(exc).__name__}: {exc}" + ) async def settle_onchain_tpos_payment(tpos_payment) -> None: @@ -186,7 +193,14 @@ async def process_paid_tpos_payment( tpos = await get_tpos(tpos_id) assert tpos - if payment.extra.get("lnaddress") and payment.extra["lnaddress"] != "": + settlement_wallet = await get_wallet(payment.wallet_id) + can_split = bool(settlement_wallet and settlement_wallet.can_send_payments) + + if ( + can_split + and payment.extra.get("lnaddress") + and payment.extra["lnaddress"] != "" + ): calc_amount = payment.amount - ((payment.amount / 100) * tpos.lnaddress_cut) address = payment.extra.get("lnaddress") if address: @@ -198,12 +212,17 @@ async def process_paid_tpos_payment( if pr: payment.extra["lnaddress"] = "" - paid_payment = await pay_invoice( - payment_request=pr, - wallet_id=payment.wallet_id, - extra={**payment.extra}, - ) - logger.debug(f"tpos: LNaddress paid cut: {paid_payment.checking_id}") + try: + paid_payment = await pay_invoice( + payment_request=pr, + wallet_id=payment.wallet_id, + extra={**payment.extra}, + ) + logger.debug( + f"tpos: LNaddress paid cut: {paid_payment.checking_id}" + ) + except Exception as exc: + logger.error(f"tpos: LNaddress cut failed: {exc}") await websocket_updater(tpos_id, json.dumps(stripped_payment)) await websocket_updater(payment.payment_hash, json.dumps(stripped_payment)) @@ -221,6 +240,10 @@ async def process_paid_tpos_payment( if not tip_amount: return + if not can_split: + logger.debug(f"tpos: tip kept in fiat wallet {payment.wallet_id}") + return + wallet_id = tpos.tip_wallet if not wallet_id: return diff --git a/tests/test_api.py b/tests/test_api.py index 837523f..de471cd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -527,9 +527,10 @@ async def test_terminal_invoice_keeps_reader_and_tap_to_pay_payload( status=PaymentState.PENDING, ) captured_invoice_data = [] + captured_wallet_ids = [] async def fake_create_payment_request(wallet_id, invoice_data): - assert wallet_id == wallet.id + captured_wallet_ids.append(wallet_id) captured_invoice_data.append(invoice_data) return payment @@ -559,6 +560,9 @@ async def fake_websocket_updater(channel, message): assert invoice_response.status_code == 201 assert invoice_response.json()["payment_request"] == "tap_to_pay" + # the card payment is booked to the TPoS fiat wallet, not the lightning one + assert tpos["fiat_wallet_id"] + assert captured_wallet_ids == [tpos["fiat_wallet_id"]] invoice_data = captured_invoice_data[0] assert invoice_data.unit == "USD" assert invoice_data.amount == 6 diff --git a/tests/test_fiat_wallet.py b/tests/test_fiat_wallet.py index c24d464..03f8800 100644 --- a/tests/test_fiat_wallet.py +++ b/tests/test_fiat_wallet.py @@ -5,13 +5,17 @@ import pytest from fastapi import HTTPException from httpx import AsyncClient +from lnbits.core.crud.payments import create_payment from lnbits.core.crud.wallets import create_wallet, delete_wallet, update_wallet +from lnbits.core.models import CreatePayment, PaymentState from lnbits.core.models.users import Account from lnbits.core.models.wallets import WalletType from lnbits.core.services.users import create_user_account_no_ckeck from lnbits.db import Connection -from tpos.crud import db # type: ignore[import] +import tpos.tasks as tpos_tasks # type: ignore[import] +import tpos.views_payments as views_payments # type: ignore[import] +from tpos.crud import db, get_latest_tpos_payments # type: ignore[import] from tpos.migrations import m028_backfill_fiat_wallets # type: ignore[import] from tpos.services_fiat import ( # type: ignore[import] create_user_fiat_wallet, @@ -83,6 +87,35 @@ async def _tpos_row(conn: Connection, tpos_id: str) -> dict: return dict(row) +async def _cash_payment( + *, + wallet_id: str, + tpos_id: str, + tip_amount: int | None = None, + fiat_method: str = "cash", +): + """A settled tpos payment, as core stores it for a fiat wallet.""" + payment_hash = uuid4().hex + return await create_payment( + f"internal_cash_{payment_hash}", + CreatePayment( + wallet_id=wallet_id, + payment_hash=payment_hash, + bolt11=f"lnbc1{payment_hash}", + amount_msat=1000, + memo="Cash sale", + extra={ + "tag": "tpos", + "tpos_id": tpos_id, + "amount": 1, + "fiat_method": fiat_method, + "tip_amount": tip_amount, + }, + ), + status=PaymentState.SUCCESS, + ) + + @pytest.mark.asyncio async def test_backfill_shares_one_fiat_wallet(): user, wallet = await _user("fiat_shared") @@ -283,7 +316,6 @@ async def test_card_payments_require_admin_enablement( refused = await client.post("/tpos/api/v1/tposs", json=payload, headers=headers) assert refused.status_code == 400 assert "Card payments are not enabled" in refused.json()["detail"] - enable_stripe(user.id) allowed = await client.post("/tpos/api/v1/tposs", json=payload, headers=headers) @@ -292,6 +324,252 @@ async def test_card_payments_require_admin_enablement( assert allowed.json()["fiat_wallet_id"] == (fiat_wallet and fiat_wallet.id) +async def _create_cash_tpos(client: AsyncClient, wallet, **overrides) -> dict: + response = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(allow_cash_settlement=True, **overrides), + headers={"X-API-KEY": wallet.adminkey}, + ) + assert response.status_code == 201, response.text + tpos = response.json() + assert tpos["fiat_wallet_id"] + return tpos + + +@pytest.mark.asyncio +async def test_cash_invoice_is_created_on_the_fiat_wallet( + client: AsyncClient, monkeypatch +): + user, wallet = await _user("fiat_cash_pay") + assert not user.super_user + tpos = await _create_cash_tpos(client, wallet) + payment = await _cash_payment(wallet_id=tpos["fiat_wallet_id"], tpos_id=tpos["id"]) + created_on = [] + + async def fake_create_payment_request(wallet_id, invoice_data): + created_on.append(wallet_id) + return payment + + queued = [] + + async def fake_internal_invoice_queue_put(checking_id): + queued.append(checking_id) + + monkeypatch.setattr( + views_payments, "create_payment_request", fake_create_payment_request + ) + monkeypatch.setattr( + views_payments, "internal_invoice_queue_put", fake_internal_invoice_queue_put + ) + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 1, + "amount_fiat": 1, + "exchange_rate": 1, + "pay_in_fiat": True, + "fiat_method": "cash", + }, + ) + + assert response.status_code == 201, response.text + assert response.json()["payment_request"] == "cash" + assert created_on == [tpos["fiat_wallet_id"]] + + validated = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/" + f"{payment.payment_hash}/cash/validate" + ) + assert validated.status_code == 200, validated.text + assert queued == [payment.checking_id] + + +@pytest.mark.asyncio +async def test_cash_invoice_needs_a_live_fiat_wallet(client: AsyncClient): + user, wallet = await _user("fiat_cash_gone") + tpos = await _create_cash_tpos(client, wallet) + await delete_wallet(user.id, tpos["fiat_wallet_id"]) + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 1, + "amount_fiat": 1, + "exchange_rate": 1, + "pay_in_fiat": True, + "fiat_method": "cash", + }, + ) + + assert response.status_code == 409 + assert "fiat wallet" in response.json()["detail"] + assert await get_latest_tpos_payments(tpos["id"]) == [] + + +@pytest.mark.asyncio +async def test_fiat_checkout_without_a_provider_stays_on_the_lightning_wallet( + client: AsyncClient, monkeypatch +): + _account, wallet = await _user("fiat_no_provider") + tpos = await _create_cash_tpos(client, wallet) + payment = await _cash_payment( + wallet_id=wallet.id, tpos_id=tpos["id"], fiat_method="checkout" + ) + created_on = [] + + async def fake_create_payment_request(wallet_id, invoice_data): + created_on.append(wallet_id) + return payment + + monkeypatch.setattr( + views_payments, "create_payment_request", fake_create_payment_request + ) + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 1, + "amount_fiat": 1, + "exchange_rate": 1, + "pay_in_fiat": True, + "fiat_method": "checkout", + }, + ) + + assert response.status_code == 201, response.text + assert created_on == [wallet.id] + + +@pytest.mark.asyncio +async def test_legacy_provider_payment_stays_on_the_lightning_wallet( + client: AsyncClient, monkeypatch, enable_stripe +): + user, wallet = await _user("fiat_legacy_pay") + enable_stripe(user.id) + created = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(fiat_provider="stripe"), + headers={"X-API-KEY": wallet.adminkey}, + ) + assert created.status_code == 201, created.text + tpos = created.json() + assert tpos["fiat_wallet_id"] + # un-backfillable legacy state: provider kept, no fiat wallet assigned + async with db.connect() as conn: + await conn.execute( + "UPDATE tpos.pos SET fiat_wallet_id = NULL WHERE id = :id", + {"id": tpos["id"]}, + ) + + payment = await _cash_payment( + wallet_id=wallet.id, tpos_id=tpos["id"], fiat_method="terminal" + ) + created_on = [] + + async def fake_create_payment_request(wallet_id, invoice_data): + created_on.append(wallet_id) + return payment + + monkeypatch.setattr( + views_payments, "create_payment_request", fake_create_payment_request + ) + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 1, + "amount_fiat": 5, + "exchange_rate": 5, + "pay_in_fiat": True, + "fiat_method": "terminal", + }, + ) + + assert response.status_code == 201, response.text + assert created_on == [wallet.id] + + +@pytest.mark.asyncio +async def test_onchain_invoice_still_requires_a_superuser(client: AsyncClient): + user, wallet = await _user("fiat_onchain_gate") + assert not user.super_user + created = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(), + headers={"X-API-KEY": wallet.adminkey}, + ) + assert created.status_code == 201, created.text + tpos = created.json() + async with db.connect() as conn: + await conn.execute( + """ + UPDATE tpos.pos + SET onchain_enabled = true, onchain_wallet_id = 'watch-wallet' + WHERE id = :id + """, + {"id": tpos["id"]}, + ) + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={"amount": 42, "payment_method": "btc_onchain"}, + ) + + assert response.status_code == 400 + assert "onchain" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_fiat_settled_sale_keeps_the_tip_and_skips_the_lnaddress_cut( + client: AsyncClient, monkeypatch +): + _account, wallet = await _user("fiat_tip") + tpos = await _create_cash_tpos(client, wallet, tip_wallet=wallet.id) + payment = await _cash_payment( + wallet_id=tpos["fiat_wallet_id"], tpos_id=tpos["id"], tip_amount=100 + ) + payment.extra["lnaddress"] = "alice@example.com" + + payouts = [] + + async def fake_pay_invoice(**kwargs): + payouts.append(kwargs) + raise AssertionError("a fiat wallet cannot send") + + async def fake_get_pr_from_lnurl(*args, **kwargs): + raise AssertionError("the lnaddress cut must be skipped for fiat settlement") + + sent = [] + + async def fake_websocket_updater(channel, message): + sent.append(channel) + + monkeypatch.setattr(tpos_tasks, "pay_invoice", fake_pay_invoice) + monkeypatch.setattr(tpos_tasks, "get_pr_from_lnurl", fake_get_pr_from_lnurl) + monkeypatch.setattr(tpos_tasks, "websocket_updater", fake_websocket_updater) + + await tpos_tasks.on_invoice_paid(payment) + + assert payouts == [] + assert sent == [tpos["id"], payment.payment_hash] + assert payment.extra["tpos_processed"] is True + assert payment.extra["tip_amount"] == 100 + + +@pytest.mark.asyncio +async def test_on_invoice_paid_survives_a_processing_error(monkeypatch): + _account, wallet = await _user("fiat_bad_payment") + payment = await _cash_payment(wallet_id=wallet.id, tpos_id="unknown-tpos") + + async def boom(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(tpos_tasks, "process_paid_tpos_payment", boom) + + await tpos_tasks.on_invoice_paid(payment) + + @pytest.mark.asyncio async def test_explicit_fiat_wallet_is_validated(client: AsyncClient): user, wallet = await _user("fiat_explicit") diff --git a/views_payments.py b/views_payments.py index ef7afd9..d0a53be 100644 --- a/views_payments.py +++ b/views_payments.py @@ -38,6 +38,7 @@ TposPayment, ) from .services import ensure_tpos_tabs_access +from .services_fiat import get_valid_tpos_fiat_wallet from .services_onchain import fetch_onchain_address from .services_tabs import get_tab_for_tpos, tab_settlement_tolerance from .views_onchain import _validate_watchonly_settings @@ -106,6 +107,17 @@ async def api_tpos_create_invoice( status_code=HTTPStatus.FORBIDDEN, detail="Onchain payments are not enabled for this TPoS.", ) + settlement_wallet = None + if cash_method or (data.pay_in_fiat and tpos.fiat_provider): + settlement_wallet = await get_valid_tpos_fiat_wallet(tpos) + if cash_method and not settlement_wallet: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=( + f"Cash settlement needs a fiat wallet in {tpos.currency} " + "for this TPoS." + ), + ) tab_settlement = data.tab_settlement if tab_settlement: user_id = await ensure_tpos_tabs_access(tpos) @@ -134,6 +146,29 @@ async def api_tpos_create_invoice( if data.pay_in_fiat: amount = (data.amount_fiat or 0.0) + (data.tip_amount_fiat or 0.0) + if cash_method or onchain_method: + wallet = await get_wallet(tpos.wallet) + account = await get_account(wallet.user) if wallet else None + if onchain_method and account and not account.is_super_user: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="This tpos cannot create onchain invoices.", + ) + if account: + existing = {label.name for label in account.extra.labels or []} + label_name = "cash" if cash_method else "onchain" + label_description = "Cash payment" if cash_method else "Onchain payment" + label_color = "#FFC107" if cash_method else "#ED8403" + if label_name not in existing: + account.extra.labels.append( + UserLabel( + name=label_name, + description=label_description, + color=label_color, + ) + ) + await update_account(account) + try: extra = { "tag": "tpos", @@ -150,31 +185,6 @@ async def api_tpos_create_invoice( } if tab_settlement: extra["tab_settlement"] = tab_settlement.dict() - if cash_method or onchain_method: - wallet = await get_wallet(tpos.wallet) - if wallet: - account = await get_account(wallet.user) - if account: - if not account.is_super_user: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="This tpos cannot create cash or onchain invoices.", - ) - existing = {label.name for label in account.extra.labels or []} - label_name = "cash" if cash_method else "onchain" - label_description = ( - "Cash payment" if cash_method else "Onchain payment" - ) - label_color = "#FFC107" if cash_method else "#ED8403" - if label_name not in existing: - account.extra.labels.append( - UserLabel( - name=label_name, - description=label_description, - color=label_color, - ) - ) - await update_account(account) if inventory_payload: extra["inventory"] = inventory_payload.dict() if data.pay_in_fiat: @@ -195,7 +205,9 @@ async def api_tpos_create_invoice( internal=bool(cash_method or onchain_method), labels=["cash"] if cash_method else (["onchain"] if onchain_method else []), ) - payment = await create_payment_request(tpos.wallet, invoice_data) + payment = await create_payment_request( + settlement_wallet.id if settlement_wallet else tpos.wallet, invoice_data + ) if cash_method: new_checking_id = f"internal_cash_{payment.payment_hash}" await update_payment_checking_id(payment.checking_id, new_checking_id) From 82b349212901f93d6d27d943f5cd6367436fb9f3 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 22 Sep 2026 14:43:39 +0100 Subject: [PATCH 06/13] feat: provision the tpos fiat wallet from the admin dialog (phase 4) - index.js: walletStatus from GET /tpos/api/v1/wallets, createFiatWallet() through the idempotent POST, explicit fiat_wallet_id on save, cash/provider form state cleared for sats, provider gate (R7c) in createOrUpdateDisabled - admin-form-dialog.js: main/tip wallet selects list lightning wallets only, fiat wallet row with create button, cash checkbox for every merchant, card-payments-disabled banner - e2e: admin dialog creates the fiat wallet and saves cash settlement, the public page shows the cash button; e2e server pins a small currency list --- static/components/admin-form-dialog.js | 66 +++++++++++++++++--- static/js/index.js | 83 +++++++++++++++++++++++++- templates/tpos/index.html | 4 ++ tests/e2e/start-tpos-server.cjs | 1 + tests/e2e/tpos.spec.ts | 65 +++++++++++++++++++- 5 files changed, 209 insertions(+), 10 deletions(-) diff --git a/static/components/admin-form-dialog.js b/static/components/admin-form-dialog.js index 84d8bc8..93c0f17 100644 --- a/static/components/admin-form-dialog.js +++ b/static/components/admin-form-dialog.js @@ -7,12 +7,26 @@ window.app.component('tpos-admin-form-dialog', { 'hasFiatProvider', 'fiatProviders', 'isFiatCurrency', + 'canCreateFiatWallet', + 'lightningWalletOptions', + 'fiatWalletOptions', 'onchainStatus', 'onchainWalletOptions', 'withdrawOptions', 'createOrUpdateDisabled' ], - emits: ['close', 'submit'], + emits: ['close', 'submit', 'create-fiat-wallet'], + watch: { + fiatWalletOptions(options) { + if ( + this.isFiatCurrency && + !this.dialog.data.fiat_wallet_id && + options.length + ) { + this.dialog.data.fiat_wallet_id = options[0].value + } + } + }, template: ` @@ -29,7 +43,7 @@ window.app.component('tpos-admin-form-dialog', { dense emit-value v-model="dialog.data.wallet" - :options="g.user.walletOptions" + :options="lightningWalletOptions" label="Wallet *" > + + + Card payments are not enabled for you. Ask your admin to enable fiat + payments. +
-
+
- - currency must be set to fiat - + currency must be set to fiat + Cash sales are credited to the fiat wallet (accounting only, + no bitcoin).
@@ -281,7 +333,7 @@ window.app.component('tpos-admin-form-dialog', { dense emit-value v-model="dialog.data.tip_wallet" - :options="g.user.walletOptions" + :options="lightningWalletOptions" label="Tip Wallet" > wallet.canReceivePayments && wallet.walletType !== 'fiat' + ) + return wallets.map(wallet => ({ + label: [wallet.name, ' - ', wallet.id.substring(0, 5), '...'].join(''), + value: wallet.id + })) + }, + fiatWalletOptions() { + const currency = (this.formDialog.data.currency || '').toUpperCase() + return this.walletStatus.fiat_wallets + .filter(wallet => (wallet.currency || '').toUpperCase() === currency) + .map(wallet => ({ + label: `${wallet.name} · ${this.formatAmount( + wallet.balance_msat / 1000, + 'sats' + )}`, + value: wallet.id + })) } }, methods: { @@ -292,6 +330,7 @@ window.app = Vue.createApp({ stripe_card_payments: false, stripe_reader_id: '', allow_cash_settlement: false, + fiat_wallet_id: null, onchain_enabled: false, onchain_wallet_id: null, onchain_zero_conf: true, @@ -351,7 +390,36 @@ window.app = Vue.createApp({ } } }, - sendTposData() { + async loadWalletStatus() { + if (!this.g.user.wallets.length) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/tpos/api/v1/wallets', + this.g.user.wallets[0].adminkey + ) + this.walletStatus = data + } catch (error) { + console.error(error) + } + }, + async createFiatWallet(currency = this.formDialog.data.currency) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/tpos/api/v1/fiat/wallets', + this.g.user.wallets[0].adminkey, + {currency} + ) + await this.loadWalletStatus() + this.formDialog.data.fiat_wallet_id = data.id + return data.id + } catch (error) { + LNbits.utils.notifyApiError(error) + return null + } + }, + async sendTposData() { const data = { ...this.formDialog.data, tip_options: @@ -384,6 +452,8 @@ window.app = Vue.createApp({ } if (data.currency === 'sats') { data.allow_cash_settlement = false + data.fiat_provider = null + data.fiat_wallet_id = null } if (!data.onchain_enabled) { data.onchain_wallet_id = null @@ -392,6 +462,14 @@ window.app = Vue.createApp({ if (!data.tabs_enabled) { data.tabs_allow_create = false } + if ( + !data.fiat_wallet_id && + (data.allow_cash_settlement || data.fiat_provider) + ) { + // On failure the API decides: cash fails loudly, an unchanged legacy + // provider row keeps its lightning wallet (R7b). + data.fiat_wallet_id = await this.createFiatWallet(data.currency) + } const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) @@ -814,6 +892,7 @@ window.app = Vue.createApp({ this.getTposs() this.loadInventoryStatus() this.loadOnchainStatus() + this.loadWalletStatus() } LNbits.api .request('GET', '/api/v1/currencies') diff --git a/templates/tpos/index.html b/templates/tpos/index.html index e9bd14f..3d0cb2b 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -369,12 +369,16 @@
{{SITE_TITLE}} TPoS extension
:has-fiat-provider="hasFiatProvider" :fiat-providers="fiatProviders" :is-fiat-currency="isFiatCurrency" + :can-create-fiat-wallet="walletStatus.can_create_fiat_wallet" + :lightning-wallet-options="lightningWalletOptions" + :fiat-wallet-options="fiatWalletOptions" :onchain-status="onchainStatus" :onchain-wallet-options="onchainWalletOptions" :withdraw-options="withdraw_options" :create-or-update-disabled="createOrUpdateDisabled" @close="closeFormDialog" @submit="sendTposData" + @create-fiat-wallet="createFiatWallet()" > { + await login(page, lnbitsServer) + const wallet = await superuserWallet(page) + const terminalName = `Cash terminal ${randomHex()}` + + await page.goto('/tpos/') + await page.getByRole('button', {name: 'New TPoS'}).click() + const form = page.locator('.q-dialog').filter({hasText: 'Name *'}).last() + await form.getByLabel('Name *').fill(terminalName) + await form.getByLabel('Wallet *').click() + await page.getByRole('option').filter({hasText: wallet.name}).click() + await form.getByLabel('Currency *').click() + await page.getByRole('option', {name: 'EUR', exact: true}).click() + const createFiatWallet = form.getByRole('button', { + name: 'Create fiat wallet (EUR)' + }) + await expect(createFiatWallet).toBeVisible() + await createFiatWallet.click() + await expect(form.getByLabel('Fiat wallet *')).toBeVisible() + await form.getByText('Allow cash settlement', {exact: true}).click() + await form.getByRole('button', {name: 'Create TPoS'}).click() + await expect( + page.locator('tr').filter({hasText: terminalName}).first() + ).toBeVisible() + + const {fiat_wallets} = (await browserJson( + page, + 'GET', + '/tpos/api/v1/wallets', + undefined, + wallet.inkey + )) as {fiat_wallets: {id: string; currency: string}[]} + expect(fiat_wallets).toHaveLength(1) + expect(fiat_wallets[0].currency).toBe('EUR') + const tposs = (await browserJson( + page, + 'GET', + '/tpos/api/v1/tposs?all_wallets=true', + undefined, + wallet.inkey + )) as {id: string; name: string; fiat_wallet_id: string | null}[] + const terminal = tposs.find(tpos => tpos.name === terminalName) + expect(terminal?.fiat_wallet_id).toBe(fiat_wallets[0].id) + + const itemName = `Cash coffee ${randomHex()}` + await browserJson( + page, + 'PUT', + `/tpos/api/v1/tposs/${terminal?.id}/items`, + {items: [{title: itemName, price: 5, tax: 0, disabled: false}]}, + wallet.adminkey + ) + await page.goto(`/tpos/${terminal?.id}`) + await page + .locator('.item-grid-title:visible') + .filter({hasText: itemName}) + .click() + await expect(page.getByRole('button', {name: 'Cash EUR'})).toBeVisible() +}) + test('ATM uses an owner session or password fallback and exits locally', async ({ page, lnbitsServer From 1e8d780729dd0217a6796a356769865e28bb5ed9 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 22 Sep 2026 14:46:42 +0100 Subject: [PATCH 07/13] docs: document cash settlement and the fiat wallet (phase 5) - README: new 'Cash Settlement & Fiat Wallet' section, feature bullet, quick link, LNbits 1.6.2 note, accounting-only/withdrawable-0 clarification - description.md: one bullet for the fiat wallet --- README.md | 25 +++++++++++++++++++++++++ description.md | 1 + 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index ad6f100..47d7a0c 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ _For video content about the TPoS extension, watch the [official demo](https://w - [Overview](#overview) - [Usage](#usage) +- [Cash Settlement & Fiat Wallet](#cash-settlement--fiat-wallet) - [Receiving Tips](#receiving-tips) - [LN Address Funding](#ln-address-funding) - [Adding Items to PoS](#adding-items-to-pos) @@ -42,6 +43,7 @@ _For video content about the TPoS extension, watch the [official demo](https://w - **Item management** — products, cart, JSON import/export - **OTC ATM** — LNURL withdraw limits and cooldown - **Stripe fiat payment integration** — accept tap-to-pay via Stripe +- **Cash settlement & fiat wallet** — cash and card sales booked to a LNbits fiat wallet - **Tax settings** — global/per-item, inclusive or exclusive ## Overview @@ -64,6 +66,29 @@ TPoS lets you take Lightning payments right from the browser. Every TPoS runs is Invoice QR +## Cash Settlement & Fiat Wallet + +Taking cash in a fiat currency no longer needs a superuser: the sale is credited to a +**LNbits fiat wallet**, so the till and the Lightning wallet stay apart. + +1. Create or edit a TPoS. +2. Set a **fiat currency** (anything but `sats`). TPoS offers **Create fiat wallet (EUR)**; + it reuses the fiat wallet you already own in that currency, and one wallet serves every + TPoS you have in it. +3. Tick **Allow cash settlement**. The public page then shows a **Cash** button next to the + Lightning one — the cashier confirms the sale with it. +4. Card payments enabled for your account by the LNbits admin (Stripe, etc.) are booked to + the same fiat wallet. + +The fiat wallet is an accounting wallet: it records what you took in cash or on card, it +cannot send, and its balance is never withdrawable. Lightning sales keep going to the +TPoS wallet, and tips / LN Address funding are not paid out from a fiat wallet (the tip +stays in the fiat wallet and on the receipt). + +> [!NOTE] +> Fiat wallets need **LNbits 1.6.2 or newer**. Cash settlement is available to every +> merchant; card payments must be enabled for your account by the LNbits admin. + ## Receiving Tips 1. Create or edit a TPoS and activate **Enable tips**. diff --git a/description.md b/description.md index 2cc6b41..55bae64 100644 --- a/description.md +++ b/description.md @@ -4,6 +4,7 @@ Its functions include: - Generating invoices - Denomination in sats and ANY fiat currency +- Cash settlement and card payments credited to a LNbits fiat wallet (accounting only) - Boltcard support - Adding items for a checkout experience - An ATM feature that allows you to sell Bitcoin back to your customers for a profit! From 5ec2d1986ec5593dedf23fb3b93da2d5edede5a6 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 22 Sep 2026 14:58:16 +0100 Subject: [PATCH 08/13] fix: harden the tpos wallet inventory endpoint (review) - GET /tpos/api/v1/wallets now needs the admin key: it returns the account's whole wallet list (ids, names, balances), which an invoice key must not enumerate (core hides even its own wallet id from an inkey). The admin UI already sends the adminkey. - tests: cash validate credits the fiat wallet and keeps withdrawable_balance at 0 (headline acceptance), public page/manifest projection never carries fiat_wallet_id, a tampered foreign fiat_wallet_id is ignored (409), cash + unsupported currency is a 400, both wallet endpoints reject an invoice key. --- tests/e2e/tpos.spec.ts | 2 +- tests/test_fiat_wallet.py | 134 +++++++++++++++++++++++++++++++++++++- views_api.py | 4 +- 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/tests/e2e/tpos.spec.ts b/tests/e2e/tpos.spec.ts index 12a6f53..d6e60a8 100644 --- a/tests/e2e/tpos.spec.ts +++ b/tests/e2e/tpos.spec.ts @@ -74,7 +74,7 @@ test('admin dialog provisions a fiat wallet for cash settlement', async ({ 'GET', '/tpos/api/v1/wallets', undefined, - wallet.inkey + wallet.adminkey )) as {fiat_wallets: {id: string; currency: string}[]} expect(fiat_wallets).toHaveLength(1) expect(fiat_wallets[0].currency).toBe('EUR') diff --git a/tests/test_fiat_wallet.py b/tests/test_fiat_wallet.py index 03f8800..57b4f6f 100644 --- a/tests/test_fiat_wallet.py +++ b/tests/test_fiat_wallet.py @@ -5,8 +5,14 @@ import pytest from fastapi import HTTPException from httpx import AsyncClient +from lnbits.core.crud import get_standalone_payment from lnbits.core.crud.payments import create_payment -from lnbits.core.crud.wallets import create_wallet, delete_wallet, update_wallet +from lnbits.core.crud.wallets import ( + create_wallet, + delete_wallet, + get_wallet, + update_wallet, +) from lnbits.core.models import CreatePayment, PaymentState from lnbits.core.models.users import Account from lnbits.core.models.wallets import WalletType @@ -17,6 +23,7 @@ import tpos.views_payments as views_payments # type: ignore[import] from tpos.crud import db, get_latest_tpos_payments # type: ignore[import] from tpos.migrations import m028_backfill_fiat_wallets # type: ignore[import] +from tpos.models import Tpos, TposClean # type: ignore[import] from tpos.services_fiat import ( # type: ignore[import] create_user_fiat_wallet, find_fiat_wallet, @@ -93,6 +100,7 @@ async def _cash_payment( tpos_id: str, tip_amount: int | None = None, fiat_method: str = "cash", + status: PaymentState = PaymentState.SUCCESS, ): """A settled tpos payment, as core stores it for a fiat wallet.""" payment_hash = uuid4().hex @@ -112,7 +120,7 @@ async def _cash_payment( "tip_amount": tip_amount, }, ), - status=PaymentState.SUCCESS, + status=status, ) @@ -324,6 +332,126 @@ async def test_card_payments_require_admin_enablement( assert allowed.json()["fiat_wallet_id"] == (fiat_wallet and fiat_wallet.id) +@pytest.mark.asyncio +async def test_cash_settlement_rejects_an_unsupported_currency(client: AsyncClient): + _account, wallet = await _user("fiat_api_unsupported") + + response = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(currency="XYZ", allow_cash_settlement=True), + headers={"X-API-KEY": wallet.adminkey}, + ) + + assert response.status_code == 400, response.text + assert "fiat wallet" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_cash_validate_credits_the_fiat_wallet(client: AsyncClient, monkeypatch): + _account, wallet = await _user("fiat_cash_credit") + tpos = await _create_cash_tpos(client, wallet) + pending = await _cash_payment( + wallet_id=tpos["fiat_wallet_id"], + tpos_id=tpos["id"], + status=PaymentState.PENDING, + ) + + async def fake_create_payment_request(wallet_id, invoice_data): + return pending + + async def fake_internal_invoice_queue_put(checking_id): + return None + + monkeypatch.setattr( + views_payments, "create_payment_request", fake_create_payment_request + ) + monkeypatch.setattr( + views_payments, "internal_invoice_queue_put", fake_internal_invoice_queue_put + ) + + created = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 1, + "amount_fiat": 1, + "exchange_rate": 1, + "pay_in_fiat": True, + "fiat_method": "cash", + }, + ) + assert created.status_code == 201, created.text + assert (await get_wallet(tpos["fiat_wallet_id"])).balance_msat == 0 + + validated = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{pending.payment_hash}/cash/validate" + ) + + assert validated.status_code == 200, validated.text + settled = await get_standalone_payment(pending.payment_hash, incoming=True) + assert settled and settled.success + credited = await get_wallet(tpos["fiat_wallet_id"]) + assert credited and credited.balance_msat == pending.amount + # the whole point of the fiat wallet: booked, never spendable + assert credited.withdrawable_balance == 0 + + +@pytest.mark.asyncio +async def test_cash_invoice_ignores_a_foreign_fiat_wallet(client: AsyncClient): + _account, wallet = await _user("fiat_foreign") + other_user, _other_wallet = await _user("fiat_foreign_other") + tpos = await _create_cash_tpos(client, wallet) + foreign = await create_user_fiat_wallet(other_user.id, "EUR") + async with db.connect() as conn: + await conn.execute( + "UPDATE tpos.pos SET fiat_wallet_id = :fiat WHERE id = :id", + {"fiat": foreign.id, "id": tpos["id"]}, + ) + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 1, + "amount_fiat": 1, + "exchange_rate": 1, + "pay_in_fiat": True, + "fiat_method": "cash", + }, + ) + + assert response.status_code == 409, response.text + assert await get_latest_tpos_payments(tpos["id"]) == [] + + +@pytest.mark.asyncio +async def test_wallet_endpoints_require_the_admin_key(client: AsyncClient): + _account, wallet = await _user("fiat_wallets_auth") + invoice_key = {"X-API-KEY": wallet.inkey} + + status = await client.get("/tpos/api/v1/wallets", headers=invoice_key) + assert status.status_code == 403 + + created = await client.post( + "/tpos/api/v1/fiat/wallets", json={"currency": "EUR"}, headers=invoice_key + ) + assert created.status_code == 403 + assert await find_fiat_wallet(wallet.user, "EUR") is None + + +@pytest.mark.asyncio +async def test_public_surfaces_never_expose_the_fiat_wallet(client: AsyncClient): + _account, wallet = await _user("fiat_public_surface") + tpos = await _create_cash_tpos(client, wallet) + assert tpos["fiat_wallet_id"] + + manifest = await client.get(f"/tpos/manifest/{tpos['id']}.webmanifest") + assert manifest.status_code == 200, manifest.text + assert "fiat_wallet_id" not in manifest.text + # the public page renders exactly this projection (views.py) + assert "fiat_wallet_id" not in TposClean(**tpos).dict() + # ...while the owner API keeps the field for the admin UI + assert Tpos(**tpos).fiat_wallet_id == tpos["fiat_wallet_id"] + + async def _create_cash_tpos(client: AsyncClient, wallet, **overrides) -> dict: response = await client.post( "/tpos/api/v1/tposs", @@ -689,7 +817,7 @@ async def test_wallet_endpoints_share_and_never_leak_keys(client: AsyncClient): assert bad_currency.status_code == 400 status = await client.get( - "/tpos/api/v1/wallets", headers={"X-API-KEY": wallet.inkey} + "/tpos/api/v1/wallets", headers={"X-API-KEY": wallet.adminkey} ) assert status.status_code == 200, status.text status_json = status.json() diff --git a/views_api.py b/views_api.py index f7fb4c0..2b095ad 100644 --- a/views_api.py +++ b/views_api.py @@ -200,8 +200,10 @@ def _wallet_option(wallet: Wallet) -> TposWalletOption: @tpos_api_router.get("/api/v1/wallets", status_code=HTTPStatus.OK) async def api_tpos_wallets( - key_info: WalletTypeInfo = Depends(require_invoice_key), + key_info: WalletTypeInfo = Depends(require_admin_key), ) -> TposWallets: + # account-wide wallet inventory (ids, names, balances): admin key only, + # like core's wallet list — an invoice key must not enumerate the account. user_id = key_info.wallet.user lightning_wallets: list[TposWalletOption] = [] fiat_wallets: list[TposWalletOption] = [] From 495b229e1aab4701f093afd6f909c41361afb865 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 22 Sep 2026 15:46:57 +0100 Subject: [PATCH 09/13] fix: require the fiat wallet before a settlement TPoS is saved Live browser review on /tpos/ (4 comments): - the fiat wallet row only shows once cash settlement is ticked or a fiat provider is picked (comment 3) - Create/Update stays disabled while a fiat-settling TPoS has no assigned wallet in its currency; an unchanged legacy provider row (R7b) keeps saving (comments 1 and 4) - a note under the cash checkbox names the required wallet/currency and turns orange until it exists (comment 2) - the wallet is auto-selected when one exists, re-picked on currency/provider change, and a stale/deleted id no longer counts as assigned - e2e covers the new gating (row hidden, button disabled, enabled after creating the wallet); README wording updated --- README.md | 12 +++-- static/components/admin-form-dialog.js | 66 +++++++++++++++++++++----- static/js/index.js | 28 +++++++---- tests/e2e/tpos.spec.ts | 11 ++++- 4 files changed, 91 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 47d7a0c..fe460f3 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,13 @@ Taking cash in a fiat currency no longer needs a superuser: the sale is credited **LNbits fiat wallet**, so the till and the Lightning wallet stay apart. 1. Create or edit a TPoS. -2. Set a **fiat currency** (anything but `sats`). TPoS offers **Create fiat wallet (EUR)**; - it reuses the fiat wallet you already own in that currency, and one wallet serves every - TPoS you have in it. -3. Tick **Allow cash settlement**. The public page then shows a **Cash** button next to the - Lightning one — the cashier confirms the sale with it. +2. Set a **fiat currency** (anything but `sats`) and tick **Allow cash settlement** (or + pick a card provider). TPoS then shows the wallet that settles those sales: your + existing fiat wallet in that currency is reused, otherwise **Create fiat wallet (EUR)** + makes one. A TPoS cannot be saved until its fiat wallet is assigned, and one wallet + serves every TPoS you have in that currency. +3. The public page then shows a **Cash** button next to the Lightning one — the cashier + confirms the sale with it. 4. Card payments enabled for your account by the LNbits admin (Stripe, etc.) are booked to the same fiat wallet. diff --git a/static/components/admin-form-dialog.js b/static/components/admin-form-dialog.js index 93c0f17..5430752 100644 --- a/static/components/admin-form-dialog.js +++ b/static/components/admin-form-dialog.js @@ -16,14 +16,43 @@ window.app.component('tpos-admin-form-dialog', { 'createOrUpdateDisabled' ], emits: ['close', 'submit', 'create-fiat-wallet'], - watch: { - fiatWalletOptions(options) { - if ( + computed: { + // the wallet row (and its "you need one" note) only exists once the TPoS + // actually settles fiat: cash settlement or a card provider + needsFiatWallet() { + const data = this.dialog.data + return ( this.isFiatCurrency && - !this.dialog.data.fiat_wallet_id && - options.length - ) { - this.dialog.data.fiat_wallet_id = options[0].value + !!(data.allow_cash_settlement || data.fiat_provider) + ) + } + }, + watch: { + 'dialog.data.currency'() { + this.pickFiatWallet() + }, + 'dialog.data.allow_cash_settlement'() { + this.pickFiatWallet() + }, + 'dialog.data.fiat_provider'() { + this.pickFiatWallet() + }, + fiatWalletOptions() { + this.pickFiatWallet() + } + }, + methods: { + // The assigned wallet is never picked by hand: an existing one in the + // TPoS currency is selected automatically, otherwise the create button + // below does it (one fiat wallet per user + currency). + pickFiatWallet() { + const data = this.dialog.data + if (!this.needsFiatWallet || !this.fiatWalletOptions.length) return + const known = this.fiatWalletOptions.some( + option => option.value === data.fiat_wallet_id + ) + if (!known) { + data.fiat_wallet_id = this.fiatWalletOptions[0].value } } }, @@ -52,11 +81,10 @@ window.app.component('tpos-admin-form-dialog', { dense emit-value v-model="dialog.data.currency" - @update:model-value="dialog.data.fiat_wallet_id = null" :options="currencyOptions" label="Currency *" >
-