+
-
- currency must be set to fiat
-
+ currency must be set to fiat
+ Cash sales are credited to the fiat wallet (accounting only,
+ no bitcoin).
+
+
+ A {{ dialog.data.currency }} fiat wallet is required — create it
+ above.
+
+
+ Sales are booked to the {{ dialog.data.currency }} fiat wallet
+ above (accounting only, no bitcoin).
+
+
@@ -281,7 +377,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.value === this.formDialog.data.fiat_wallet_id
+ )
+ },
onchainWalletOptions() {
return (this.onchainStatus.wallets || []).map(wallet => ({
label: wallet.title,
value: wallet.id
}))
+ },
+ lightningWalletOptions() {
+ const wallets = this.walletStatus.lightning_wallets.length
+ ? this.walletStatus.lightning_wallets
+ : this.g.user.wallets.filter(
+ 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, value: wallet.id}))
}
},
methods: {
@@ -292,6 +336,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 +396,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 +458,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 +468,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 +898,7 @@ window.app = Vue.createApp({
this.getTposs()
this.loadInventoryStatus()
this.loadOnchainStatus()
+ this.loadWalletStatus()
}
LNbits.api
.request('GET', '/api/v1/currencies')
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/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()"
>
{
export async function superuserWallet(page: Page): Promise {
return page.evaluate(() => {
- const wallet = (window as typeof window & {g: {user: {wallets: Wallet[]}}})
- .g.user.wallets[0]
+ // accounts also own fiat wallets now, and they sort before the lightning
+ // one: pick a wallet that can actually receive, like the admin form does
+ type MappedWallet = Wallet & {
+ walletType?: string
+ canReceivePayments?: boolean
+ }
+ const wallets = (
+ window as typeof window & {g: {user: {wallets: MappedWallet[]}}}
+ ).g.user.wallets
+ const wallet =
+ wallets.find(w => w.walletType !== 'fiat' && w.canReceivePayments) ??
+ wallets[0]
return {
adminkey: wallet.adminkey,
id: wallet.id,
diff --git a/tests/e2e/start-tpos-server.cjs b/tests/e2e/start-tpos-server.cjs
index 19b8a57..1adfdd2 100644
--- a/tests/e2e/start-tpos-server.cjs
+++ b/tests/e2e/start-tpos-server.cjs
@@ -43,6 +43,7 @@ const server = childProcess.spawn(
DEBUG: 'true',
HOST: host,
LNBITS_ADMIN_UI: 'true',
+ LNBITS_ALLOWED_CURRENCIES: 'EUR,USD,GBP',
LNBITS_BACKEND_WALLET_CLASS: 'FakeWallet',
LNBITS_DATABASE_URL: '',
LNBITS_DATA_FOLDER: dataDir,
diff --git a/tests/e2e/tpos.spec.ts b/tests/e2e/tpos.spec.ts
index d460756..aa7173f 100644
--- a/tests/e2e/tpos.spec.ts
+++ b/tests/e2e/tpos.spec.ts
@@ -1,4 +1,4 @@
-import {test, expect, randomHex} from './fixtures'
+import {test, expect, browserJson, randomHex} from './fixtures'
import {
createTpos,
createWallet,
@@ -41,6 +41,76 @@ test('admin can create a terminal and add an item through the extracted dialogs'
await expect(page.getByText(itemName, {exact: true})).toBeVisible()
})
+test('admin dialog provisions a fiat wallet for cash settlement', async ({
+ page,
+ lnbitsServer
+}) => {
+ 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 createTpos = form.getByRole('button', {name: 'Create TPoS'})
+ const createFiatWallet = form.getByRole('button', {
+ name: 'Create fiat wallet (EUR)'
+ })
+ // neither cash nor a provider yet: no wallet row, TPoS saves as-is
+ await expect(createFiatWallet).toHaveCount(0)
+ await expect(createTpos).toBeEnabled()
+ await form.getByText('Allow cash settlement', {exact: true}).click()
+ // cash settlement without a wallet must not be creatable
+ await expect(createFiatWallet).toBeVisible()
+ await expect(createTpos).toBeDisabled()
+ await createFiatWallet.click()
+ await expect(form.getByLabel('Fiat wallet *')).toBeVisible()
+ await expect(createTpos).toBeEnabled()
+ await createTpos.click()
+ await expect(
+ page.locator('tr').filter({hasText: terminalName}).first()
+ ).toBeVisible()
+
+ const {fiat_wallets} = (await browserJson(
+ page,
+ 'GET',
+ '/tpos/api/v1/wallets',
+ undefined,
+ wallet.adminkey
+ )) 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
diff --git a/tests/test_api.py b/tests/test_api.py
index 1a9795f..de471cd 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",
@@ -526,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
@@ -558,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
new file mode 100644
index 0000000..1df0de9
--- /dev/null
+++ b/tests/test_fiat_wallet.py
@@ -0,0 +1,873 @@
+from datetime import datetime, timedelta, timezone
+from typing import Any
+from uuid import uuid4
+
+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,
+ 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
+from lnbits.core.services.users import create_user_account_no_ckeck
+from lnbits.db import Connection
+
+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.models import Tpos, TposClean # 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]
+
+
+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,
+ *,
+ 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)
+
+
+async def _cash_payment(
+ *,
+ wallet_id: str,
+ 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
+ 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=status,
+ )
+
+
+@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_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")
+ 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")
+
+ assert wallet.id == again.id
+ assert wallet.name == "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) == []
+
+
+@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_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
+ fiat_wallet = await get_wallet(tpos["fiat_wallet_id"])
+ assert fiat_wallet and fiat_wallet.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",
+ 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")
+ 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.adminkey}
+ )
+ 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 all(
+ set(item) == {"id", "name", "currency", "balance_msat"}
+ for item in status_json["lightning_wallets"] + status_json["fiat_wallets"]
+ )
+
+
+@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..3312020 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_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] = []
+ 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)
+ 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)
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)