Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -64,6 +66,33 @@ TPoS lets you take Lightning payments right from the browser. Every TPoS runs is

<img src="https://github.com/user-attachments/assets/1d5341e2-cfba-45d5-b2c5-99f61a3d07a4" alt="Invoice QR" width="720">

## 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**.
Expand Down
4 changes: 2 additions & 2 deletions config.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions description.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
73 changes: 73 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

from lnbits.db import Database


Expand Down Expand Up @@ -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';
""")
21 changes: 21 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
156 changes: 156 additions & 0 deletions services_fiat.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading