diff --git a/README.md b/README.md index ad6f100..38b7867 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,33 @@ 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`) 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. It is named after the currency (e.g. + `EUR`) so any other extension that settles in fiat can reuse it, and you can rename it + in LNbits whenever you like. +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. + +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/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", 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! diff --git a/migrations.py b/migrations.py index 83e89ef..051cb4a 100644 --- a/migrations.py +++ b/migrations.py @@ -1,3 +1,5 @@ +from typing import Any + from lnbits.db import Database @@ -333,3 +335,74 @@ 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 loguru import logger + + from .services_fiat import create_user_fiat_wallet + + 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: + 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"], + (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"]}, + ) + 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..204dc52 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,26 @@ def can_withdraw(self) -> bool: class Tpos(TposClean, BaseModel): wallet: str tip_wallet: str | None = None + 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) class TposPaymentStatus(str, Enum): diff --git a/services_fiat.py b/services_fiat.py new file mode 100644 index 0000000..b2740cb --- /dev/null +++ b/services_fiat.py @@ -0,0 +1,156 @@ +from http import HTTPStatus + +from fastapi import HTTPException +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 +) -> 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, + 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, + # the wallet is the account's fiat wallet for that currency: every + # extension that settles in fiat reuses it, so it is named after the + # currency and the merchant can rename it in LNbits + wallet_name=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 + + +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/static/components/admin-form-dialog.js b/static/components/admin-form-dialog.js index 84d8bc8..5430752 100644 --- a/static/components/admin-form-dialog.js +++ b/static/components/admin-form-dialog.js @@ -7,12 +7,55 @@ 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'], + 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 && + !!(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 + } + } + }, template: ` @@ -29,7 +72,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). +

+ + 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)