Receipt printing is an experimental feature. Not all devices work diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..58304de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +import os +from typing import Any, cast + +import pytest_asyncio +import tabs.migrations as tabs_migrations # type: ignore[import] +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from lnbits.core import migrations as core_migrations # type: ignore[import] +from lnbits.core.crud.extensions import create_installed_extension +from lnbits.core.db import db as core_db +from lnbits.core.helpers import run_migration +from lnbits.core.models.extensions import InstallableExtension +from lnbits.settings import settings +from tabs import tabs_ext # type: ignore[import] +from tabs.crud import db as tabs_db # type: ignore[import] + +import tpos.migrations as ext_migrations # type: ignore[import] +import tpos.services as tpos_services # type: ignore[import] +from tpos import tpos_ext # type: ignore[import] +from tpos.crud import db # type: ignore[import] + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def init_ext(): + if os.path.isfile(core_db.path): + os.remove(core_db.path) + async with core_db.connect() as conn: + await run_migration(conn, core_migrations, "core") + await create_installed_extension( + InstallableExtension( + id="tabs", + name="Tabs", + version="0.0.0", + active=True, + ), + conn=conn, + ) + settings.lnbits_installed_extensions_ids.add("tabs") + + if os.path.isfile(db.path): + os.remove(db.path) + async with db.connect() as conn: + await run_migration(conn, ext_migrations, "tpos") + + if os.path.isfile(tabs_db.path): + os.remove(tabs_db.path) + async with tabs_db.connect() as conn: + await run_migration(conn, tabs_migrations, "tabs") + + +@pytest_asyncio.fixture +async def client(monkeypatch): + app = FastAPI() + app.include_router(tpos_ext) + app.include_router(tabs_ext) + transport = ASGITransport(app=cast(Any, app)) + + def app_client(*args, **kwargs): + kwargs["transport"] = transport + kwargs.setdefault("base_url", "http://testserver") + return AsyncClient(*args, **kwargs) + + monkeypatch.setattr(tpos_services.httpx, "AsyncClient", app_client) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3559d22 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,280 @@ +import asyncio +import json +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from lnbits.core.crud import get_standalone_payment +from lnbits.core.models.users import Account +from lnbits.core.services import pay_invoice, update_wallet_balance +from lnbits.core.services.users import create_user_account_no_ckeck +from lnbits.settings import settings +from lnbits.tasks import internal_invoice_queue +from tabs.crud import ( # type: ignore[import] + get_tab_by_id, + get_tab_entries, + get_tab_settlements, +) + +from tpos.crud import get_tpos, get_tpos_payment_by_hash # type: ignore[import] +from tpos.tasks import on_invoice_paid # type: ignore[import] + + +def _tpos_payload(**overrides): + payload = { + "wallet": None, + "name": "Main TPoS", + "currency": "sats", + "business_name": "Main 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 _user_with_tabs(username: str = "tposuser"): + account = Account(id=uuid4().hex, username=username) + user = await create_user_account_no_ckeck(account=account, default_exts=["tabs"]) + return user, user.wallets[0] + + +async def _drain_internal_invoice_queue() -> None: + while True: + try: + internal_invoice_queue.get_nowait() + except asyncio.QueueEmpty: + return + + +@pytest.mark.asyncio +async def test_tpos_crud_settings_and_wrapper_token(client: AsyncClient): + user, wallet = await _user_with_tabs() + settings.super_user = user.id + headers = {"X-API-KEY": wallet.adminkey} + + listed_empty = await client.get("/tpos/api/v1/tposs", headers=headers) + assert listed_empty.status_code == 200 + assert listed_empty.json() == [] + + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(currency="EUR", allow_cash_settlement=True), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + assert tpos["wallet"] == wallet.id + assert tpos["allow_cash_settlement"] is True + + listed = await client.get("/tpos/api/v1/tposs?all_wallets=true", headers=headers) + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()] == [tpos["id"]] + + update = await client.put( + f"/tpos/api/v1/tposs/{tpos['id']}", + json=_tpos_payload( + name="Updated TPoS", + currency="EUR", + allow_cash_settlement=True, + tabs_enabled=True, + tabs_allow_create=True, + inventory_tags=["coffee", "tea"], + inventory_omit_tags=["hidden"], + ), + headers=headers, + ) + assert update.status_code == 200 + updated = update.json() + assert updated["name"] == "Updated TPoS" + assert updated["tabs_enabled"] is True + assert updated["tabs_allow_create"] is True + assert updated["inventory_tags"] == "coffee,tea" + + token = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/wrapper-token", headers=headers + ) + assert token.status_code == 200 + assert token.json()["auth"] + assert token.json()["expiration_time_minutes"] > 500_000 + + items = await client.put( + f"/tpos/api/v1/tposs/{tpos['id']}/items", + json={ + "items": [ + { + "image": None, + "price": 2.5, + "title": "Coffee", + "description": "Hot", + "tax": 10, + "disabled": False, + "categories": ["coffee"], + } + ] + }, + headers=headers, + ) + assert items.status_code == 201 + assert json.loads(items.json()["items"])[0]["title"] == "Coffee" + + delete = await client.delete(f"/tpos/api/v1/tposs/{tpos['id']}", headers=headers) + assert delete.status_code == 200 + assert await get_tpos(tpos["id"]) is None + + +@pytest.mark.asyncio +async def test_tabs_endpoints_use_real_tabs_api(client: AsyncClient): + _user, wallet = await _user_with_tabs("tabsuser") + headers = {"X-API-KEY": wallet.adminkey} + + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(tabs_enabled=True, tabs_allow_create=True), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + + create_tab = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/tabs", + json={ + "name": "Patio", + "customer_name": "Alice", + "reference": "Table 7", + }, + ) + assert create_tab.status_code == 200 + tab = create_tab.json() + assert tab["name"] == "Patio" + assert tab["currency"] == "sats" + + tabs = await client.get(f"/tpos/api/v1/tposs/{tpos['id']}/tabs?status=open") + assert tabs.status_code == 200 + assert [item["id"] for item in tabs.json()["data"]] == [tab["id"]] + + charge = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/tabs/{tab['id']}/charges", + json={ + "amount": 25000, + "description": "Drinks", + "items": [{"title": "Coffee", "quantity": 2, "price": 12500}], + "idempotency_key": "tpos-charge-1", + }, + ) + assert charge.status_code == 200 + charge_payload = charge.json() + assert charge_payload["entry"]["entry_type"] == "charge" + assert charge_payload["entry"]["amount"] == 25000 + assert charge_payload["tab"]["balance"] == 25000 + + entries = await get_tab_entries(tab["id"]) + assert len(entries) == 1 + assert entries[0].source == "tpos" + + +@pytest.mark.asyncio +async def test_paid_tpos_invoice_settles_tab_via_real_tabs_api(client: AsyncClient): + await _drain_internal_invoice_queue() + _user, wallet = await _user_with_tabs("settlementuser") + headers = {"X-API-KEY": wallet.adminkey} + + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(tabs_enabled=True, tabs_allow_create=True), + headers=headers, + ) + tpos = create.json() + create_tab = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/tabs", + json={"name": "Counter"}, + ) + tab = create_tab.json() + charge = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/tabs/{tab['id']}/charges", + json={ + "amount": 21, + "description": "Cake", + "idempotency_key": "tpos-charge-settlement", + }, + ) + assert charge.status_code == 200 + + invoice_response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 21, + "memo": "Settle tab", + "tab_settlement": { + "tab_id": tab["id"], + "amount": 21, + "reference": "counter-close", + "description": "TPoS settlement", + "idempotency_key": "tpos-settlement-1", + }, + }, + ) + assert invoice_response.status_code == 201 + invoice = invoice_response.json() + + await update_wallet_balance(wallet, 100) + await _drain_internal_invoice_queue() + await pay_invoice(wallet_id=wallet.id, payment_request=invoice["bolt11"]) + await _drain_internal_invoice_queue() + + payment = await get_standalone_payment(invoice["payment_hash"], incoming=True) + assert payment is not None + await on_invoice_paid(payment) + + tpos_payment = await get_tpos_payment_by_hash(invoice["payment_hash"]) + assert tpos_payment is not None + assert tpos_payment.paid is True + + settled_tab = await get_tab_by_id(tab["id"]) + assert settled_tab is not None + assert settled_tab.balance == 0 + assert settled_tab.status == "closed" + + settlements = await get_tab_settlements(tab["id"]) + assert len(settlements) == 1 + assert settlements[0].status == "completed" + assert settlements[0].method == "other" + assert settlements[0].idempotency_key == "tpos-settlement-1" + + +@pytest.mark.asyncio +async def test_tpos_rejects_invalid_tab_flows(client: AsyncClient): + _user, wallet = await _user_with_tabs("invalidtabsuser") + headers = {"X-API-KEY": wallet.adminkey} + + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(tabs_enabled=False, tabs_allow_create=True), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + assert tpos["tabs_allow_create"] is False + + tabs_disabled = await client.get(f"/tpos/api/v1/tposs/{tpos['id']}/tabs") + assert tabs_disabled.status_code == 400 + + update = await client.put( + f"/tpos/api/v1/tposs/{tpos['id']}", + json=_tpos_payload(tabs_enabled=True, tabs_allow_create=False), + headers=headers, + ) + assert update.status_code == 200 + + create_denied = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/tabs", + json={"name": "Denied"}, + ) + assert create_denied.status_code == 403 diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 0000000..f5b83ed --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,85 @@ +import asyncio + +import pytest +from httpx import AsyncClient +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 tpos.crud import get_tpos_payment_by_hash # type: ignore[import] +from tpos.tasks import on_invoice_paid # type: ignore[import] + + +async def _drain_internal_invoice_queue() -> None: + while True: + try: + internal_invoice_queue.get_nowait() + except asyncio.QueueEmpty: + return + + +@pytest.mark.asyncio +async def test_tpos_invoice_can_be_paid_through_api_flow(client: AsyncClient): + await _drain_internal_invoice_queue() + user = await create_user_account_no_ckeck() + wallet = user.wallets[0] + headers = {"X-API-KEY": wallet.adminkey} + + create_tpos_response = await client.post( + "/tpos/api/v1/tposs", + json={ + "wallet": wallet.id, + "name": "Main Bar", + "currency": "sats", + "business_name": None, + "business_address": None, + "business_vat_id": None, + }, + headers=headers, + ) + assert create_tpos_response.status_code == 201 + tpos = create_tpos_response.json() + + invoice_response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={"amount": 21, "memo": "Table 4"}, + ) + assert invoice_response.status_code == 201 + invoice = invoice_response.json() + assert invoice["payment_hash"] + assert invoice["bolt11"] + assert invoice["payment_request"].startswith("lightning:") + + check_response = await client.get( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}" + ) + assert check_response.status_code == 200 + assert check_response.json() == {"paid": False} + + await update_wallet_balance(wallet, 100) + await _drain_internal_invoice_queue() + await pay_invoice(wallet_id=wallet.id, payment_request=invoice["bolt11"]) + await _drain_internal_invoice_queue() + + payment = await get_standalone_payment(invoice["payment_hash"], incoming=True) + assert payment is not None + await on_invoice_paid(payment) + + paid_response = await client.get( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}" + ) + assert paid_response.status_code == 200 + assert paid_response.json() == {"paid": True} + + tpos_payment = await get_tpos_payment_by_hash(invoice["payment_hash"]) + assert tpos_payment is not None + assert tpos_payment.paid is True + assert tpos_payment.payment_method == "lightning" + + latest_response = await client.get(f"/tpos/api/v1/tposs/{tpos['id']}/invoices") + assert latest_response.status_code == 200 + latest = latest_response.json() + assert len(latest) == 1 + assert latest[0]["pending"] is False + assert latest[0]["payment_method"] == "lightning" diff --git a/views_api.py b/views_api.py index 7105934..e0e00ec 100644 --- a/views_api.py +++ b/views_api.py @@ -56,6 +56,8 @@ from .models import ( CreateTposData, CreateTposInvoice, + CreateTposTabCharge, + CreateTposTabData, CreateUpdateItemData, InventorySale, PayLnurlWData, @@ -69,9 +71,16 @@ Tpos, TposInvoiceResponse, TposPayment, + TposTab, + TposTabList, ) from .services import ( + create_tab_charge_for_tpos, + create_tab_for_tpos, + ensure_tpos_tabs_access, fetch_onchain_address, + fetch_single_tab_for_tpos, + fetch_tabs_for_tpos, fetch_watchonly_config, fetch_watchonly_wallet, fetch_watchonly_wallets, @@ -84,6 +93,31 @@ tpos_api_router = APIRouter() +async def _get_tpos_or_404(tpos_id: str) -> Tpos: + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + return tpos + + +def _tpos_currency(tpos: Tpos) -> str: + return (tpos.currency or "sats").lower() + + +def _ensure_tab_matches_tpos_currency(tab: dict[str, Any], tpos: Tpos) -> None: + if (tab.get("currency") or "sats").lower() != _tpos_currency(tpos): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Tab currency must match TPoS currency.", + ) + + +def _tab_settlement_tolerance(currency: str | None) -> float: + return 1 if (currency or "sats").lower() == "sats" else 0.01 + + def _two_year_token_expiry_minutes() -> int: now = datetime.now(timezone.utc) try: @@ -294,6 +328,97 @@ async def api_onchain_status( return await _get_watchonly_status(key_info.wallet) +@tpos_api_router.get("/api/v1/tposs/{tpos_id}/tabs", response_model=TposTabList) +async def api_tpos_tabs( + tpos_id: str, + status: str = Query("open"), + q: str | None = Query(None), +) -> TposTabList: + tpos = await _get_tpos_or_404(tpos_id) + user_id = await ensure_tpos_tabs_access(tpos) + tabs = await fetch_tabs_for_tpos( + user_id=user_id, + wallet_id=tpos.wallet, + status=status, + query=q, + ) + return TposTabList(data=[TposTab(**tab) for tab in tabs]) + + +@tpos_api_router.post("/api/v1/tposs/{tpos_id}/tabs", response_model=TposTab) +async def api_tpos_create_tab( + tpos_id: str, + data: CreateTposTabData, +) -> TposTab: + tpos = await _get_tpos_or_404(tpos_id) + user_id = await ensure_tpos_tabs_access(tpos) + if not tpos.tabs_allow_create: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Tab creation is not enabled for this TPoS.", + ) + tab_currency = (data.currency or _tpos_currency(tpos)).lower() + if tab_currency != _tpos_currency(tpos): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Tab currency must match TPoS currency.", + ) + + payload = { + "wallet": tpos.wallet, + "name": data.name, + "customer_name": data.customer_name, + "reference": data.reference, + "currency": tab_currency, + "limit_type": data.limit_type, + "limit_amount": data.limit_amount, + } + tab = await create_tab_for_tpos(user_id=user_id, payload=payload) + return TposTab(**tab) + + +@tpos_api_router.post("/api/v1/tposs/{tpos_id}/tabs/{tab_id}/charges") +async def api_tpos_add_tab_charge( + tpos_id: str, + tab_id: str, + data: CreateTposTabCharge, +) -> dict[str, Any]: + tpos = await _get_tpos_or_404(tpos_id) + user_id = await ensure_tpos_tabs_access(tpos) + + tab = await fetch_single_tab_for_tpos(user_id=user_id, tab_id=tab_id) + _ensure_tab_matches_tpos_currency(tab, tpos) + + metadata = { + "source": "tpos", + "tpos_id": tpos.id, + "tpos_name": tpos.name, + "currency": tpos.currency, + "amount": data.amount, + "items": data.items, + "notes": data.notes, + "internal_memo": data.internal_memo, + "created_at": datetime.now(timezone.utc).isoformat(), + } + payload = { + "entry_type": "charge", + "amount": data.amount, + "description": data.description or "TPoS order charge", + "metadata": json.dumps(metadata), + "source": "tpos", + "source_id": tpos.id, + "source_action": "order_charge", + "idempotency_key": data.idempotency_key, + } + entry = await create_tab_charge_for_tpos( + user_id=user_id, + tab_id=tab_id, + payload=payload, + ) + updated_tab = await fetch_single_tab_for_tpos(user_id=user_id, tab_id=tab_id) + return {"tab_id": tab_id, "entry": entry, "tab": TposTab(**updated_tab).dict()} + + @tpos_api_router.post("/api/v1/tposs", status_code=HTTPStatus.CREATED) async def api_tpos_create( data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -304,6 +429,8 @@ async def api_tpos_create( onchain_enabled=data.onchain_enabled, onchain_wallet_id=data.onchain_wallet_id, ) + 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 @@ -338,6 +465,7 @@ async def api_tpos_update( raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") user = await get_user(wallet.wallet.user) update_payload = data.dict(exclude_unset=True) + update_payload.pop("wallet", None) desired_onchain_enabled = update_payload.get( "onchain_enabled", tpos.onchain_enabled ) @@ -349,6 +477,9 @@ async def api_tpos_update( onchain_enabled=desired_onchain_enabled, onchain_wallet_id=desired_onchain_wallet_id, ) + desired_tabs_enabled = update_payload.get("tabs_enabled", tpos.tabs_enabled) + if not desired_tabs_enabled: + update_payload["tabs_allow_create"] = False desired_currency = update_payload.get("currency", tpos.currency) if desired_currency == "sats": update_payload["allow_cash_settlement"] = False @@ -546,6 +677,32 @@ async def api_tpos_create_invoice( status_code=HTTPStatus.FORBIDDEN, detail="Onchain payments are not enabled for this TPoS.", ) + tab_settlement = data.tab_settlement + if tab_settlement: + user_id = await ensure_tpos_tabs_access(tpos) + tab = await fetch_single_tab_for_tpos( + user_id=user_id, tab_id=tab_settlement.tab_id + ) + _ensure_tab_matches_tpos_currency(tab, tpos) + if tab.get("status") == "closed": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Closed tabs cannot be settled.", + ) + tab_balance = float(tab.get("balance") or 0) + if tab_balance <= 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="This tab has no outstanding balance to settle.", + ) + amount_over_balance = tab_settlement.amount - tab_balance + if amount_over_balance > _tab_settlement_tolerance(tab.get("currency")): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Settlement amount cannot exceed the outstanding balance.", + ) + if amount_over_balance > 0: + tab_settlement.amount = tab_balance currency = tpos.currency if data.pay_in_fiat else "sat" amount = data.amount + (data.tip_amount or 0.0) if data.pay_in_fiat: @@ -565,6 +722,8 @@ async def api_tpos_create_invoice( "paid_in_fiat": data.pay_in_fiat, "base_url": str(request.base_url), } + if tab_settlement: + extra["tab_settlement"] = tab_settlement.dict() if cash_method or onchain_method: wallet = await get_wallet(tpos.wallet) if wallet: