diff --git a/.gitignore b/.gitignore index 0152b6e..2a2a77b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__ node_modules .mypy_cache .venv + +.codex diff --git a/helpers.py b/helpers.py index 5abe675..2d8c3af 100644 --- a/helpers.py +++ b/helpers.py @@ -1,5 +1,6 @@ import json +from lnbits.helpers import create_access_token from loguru import logger @@ -64,3 +65,7 @@ def normalize_image(val: str | None) -> str | None: if val.startswith("http") or val.startswith("/api/") or val.startswith("data:"): return val return f"/api/v1/assets/{val}/thumbnail" + + +def create_internal_user_access_token(user_id: str) -> str: + return create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) diff --git a/migrations.py b/migrations.py index 886c670..f43f2f3 100644 --- a/migrations.py +++ b/migrations.py @@ -312,3 +312,15 @@ async def m024_add_assetlinks_cache(db: Database): updated_at INTEGER NOT NULL DEFAULT 0 ); """) + + +async def m025_add_tabs_integration_settings(db: Database): + """ + Add tabs integration settings. + """ + await db.execute(""" + ALTER TABLE tpos.pos ADD tabs_enabled BOOLEAN DEFAULT false; + """) + await db.execute(""" + ALTER TABLE tpos.pos ADD tabs_allow_create BOOLEAN DEFAULT false; + """) diff --git a/models.py b/models.py index fdef6ab..f6f3489 100644 --- a/models.py +++ b/models.py @@ -16,6 +16,14 @@ class CreateWithdrawPay(BaseModel): pay_link: str +class CreateTposInvoiceTabSettlement(BaseModel): + tab_id: str = Field(..., min_length=1) + amount: float = Field(..., gt=0) + reference: str | None = Field(None, max_length=120) + description: str | None = Field(None, max_length=512) + idempotency_key: str = Field(..., min_length=8, max_length=128) + + class CreateTposInvoice(BaseModel): amount: int = Query(..., ge=1) memo: str | None = Query(None) @@ -31,6 +39,7 @@ class CreateTposInvoice(BaseModel): payment_method: str | None = Query(None) amount_fiat: float | None = Query(None, ge=0.0) tip_amount_fiat: float | None = Query(None, ge=0.0) + tab_settlement: CreateTposInvoiceTabSettlement | None = Query(None) class InventorySaleItem(BaseModel): @@ -77,6 +86,8 @@ class CreateTposData(BaseModel): onchain_enabled: bool = Field(False) onchain_wallet_id: str | None = None onchain_zero_conf: bool = Field(True) + tabs_enabled: bool = Field(False) + tabs_allow_create: bool = Field(False) @validator("tax_default", pre=True, always=True) def default_tax_when_none(cls, v): @@ -117,6 +128,8 @@ class TposClean(BaseModel): onchain_enabled: bool = False onchain_wallet_id: str | None = None onchain_zero_conf: bool = True + tabs_enabled: bool = False + tabs_allow_create: bool = False @property def withdraw_maximum(self) -> int: @@ -170,6 +183,39 @@ class TposInvoiceResponse(BaseModel): extra: dict[str, Any] = Field(default_factory=dict) +class TposTab(BaseModel): + id: str + name: str + customer_name: str | None = None + reference: str | None = None + currency: str = "sats" + status: str = "open" + balance: float = 0 + is_archived: bool = False + + +class TposTabList(BaseModel): + data: list[TposTab] = Field(default_factory=list) + + +class CreateTposTabData(BaseModel): + name: str = Field(..., min_length=1, max_length=120) + customer_name: str | None = None + reference: str | None = None + currency: str | None = None + limit_type: str = "none" + limit_amount: float | None = None + + +class CreateTposTabCharge(BaseModel): + amount: float = Field(..., gt=0) + description: str | None = Field(None, max_length=512) + items: list[dict[str, Any]] = Field(default_factory=list, max_items=200) + notes: dict[str, Any] | None = None + internal_memo: str | None = Field(None, max_length=512) + idempotency_key: str = Field(..., min_length=8, max_length=128) + + class LnurlCharge(BaseModel): id: str tpos_id: str diff --git a/pyproject.toml b/pyproject.toml index 3a8e9f5..d3c5420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,8 @@ authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/tpos" } dependencies = [ "lnbits>1" ] -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "black", "pytest-asyncio", "pytest", diff --git a/services.py b/services.py index d7c33b6..a69efdc 100644 --- a/services.py +++ b/services.py @@ -1,297 +1,34 @@ -import time -from typing import Any +from http import HTTPStatus -import httpx -from lnbits.core.crud import ( - get_installed_extension, - get_user_active_extensions_ids, - get_wallet, -) -from lnbits.core.models import User -from lnbits.helpers import create_access_token -from lnbits.settings import settings -from loguru import logger - -from .crud import get_wrapper_assetlinks_cache, set_wrapper_assetlinks_cache -from .helpers import from_csv, inventory_tags_to_list +from fastapi import HTTPException +from lnbits.core.crud import get_wallet -WRAPPER_ASSETLINKS_URL = ( - "https://github.com/lnbits/TPoS-Stripe-Tap-to-Pay-Wrapper-Stripev5" - "/releases/latest/download/assetlinks.json" +from .models import Tpos +from .services_tabs import ( + tabs_available_for_user, ) -WRAPPER_ASSETLINKS_CACHE_SECONDS = 60 * 60 - - -async def fetch_wrapper_assetlinks() -> dict | list: - now = int(time.time()) - cached = await get_wrapper_assetlinks_cache() - if cached: - cached_assetlinks, cached_at = cached - cache_fresh = now - cached_at < WRAPPER_ASSETLINKS_CACHE_SECONDS - if cache_fresh: - return cached_assetlinks - - try: - async with httpx.AsyncClient( - follow_redirects=True, headers={"User-Agent": settings.user_agent} - ) as client: - resp = await client.get(WRAPPER_ASSETLINKS_URL, timeout=10) - resp.raise_for_status() - assetlinks = resp.json() - except Exception as exc: - if cached: - logger.warning(f"Using cached TPoS wrapper assetlinks.json: {exc!s}") - return cached[0] - raise RuntimeError("Unable to fetch TPoS wrapper assetlinks.json.") from exc - - if not isinstance(assetlinks, (dict, list)): - if cached: - logger.warning("Using cached TPoS wrapper assetlinks.json: invalid JSON") - return cached[0] - raise RuntimeError("TPoS wrapper assetlinks.json is not valid JSON.") - await set_wrapper_assetlinks_cache(assetlinks, now) - return assetlinks - -async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: - wallet = await get_wallet(wallet_id) +async def get_tpos_owner_user_id(tpos: Tpos) -> str: + wallet = await get_wallet(tpos.wallet) if not wallet: - return - inventory_id = inventory_payload.get("inventory_id") - items = inventory_payload.get("items") or [] - if not inventory_id or not items: - return - items_to_update = [] - for item in items: - item_id = item.get("id") - qty = item.get("quantity") or 0 - if not item_id or qty <= 0: - continue - items_to_update.append({"id": item_id, "quantity": int(qty)}) - if not items_to_update: - return - - ids = [item["id"] for item in items_to_update] - quantities = [item["quantity"] for item in items_to_update] - - # Needed to accomodate admin users, as using user ID is not possible - access = create_access_token( - {"sub": "", "usr": wallet.user}, token_expire_minutes=1 - ) - async with httpx.AsyncClient() as client: - await client.patch( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/quantities", - headers={"Authorization": f"Bearer {access}"}, - params={"source": "tpos", "ids": ids, "quantities": quantities}, + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="TPoS is not ready for tabs integration.", ) - return + return wallet.user -async def get_default_inventory(user_id: str) -> dict[str, Any] | None: - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1", - headers={"Authorization": f"Bearer {access}"}, +async def ensure_tpos_tabs_access(tpos: Tpos) -> str: + if not tpos.tabs_enabled: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Tabs integration is not enabled for this TPoS.", ) - inventory = resp.json() - if not inventory: - return None - if isinstance(inventory, list): - inventory = inventory[0] if inventory else None - if not isinstance(inventory, dict): - return None - inventory["tags"] = inventory_tags_to_list(inventory.get("tags")) - inventory["omit_tags"] = inventory_tags_to_list(inventory.get("omit_tags")) - return inventory - - -async def get_inventory_items_for_tpos( - user_id: str, - inventory_id: str, - tags: str | list[str] | None, - omit_tags: str | list[str] | None, -) -> list[Any]: - tag_list = inventory_tags_to_list(tags) - omit_list = [tag.lower() for tag in inventory_tags_to_list(omit_tags)] - allowed_tags = [tag.lower() for tag in tag_list] - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/paginated", - headers={"Authorization": f"Bearer {access}"}, - params={"limit": 500, "offset": 0, "is_active": True}, + user_id = await get_tpos_owner_user_id(tpos) + if not await tabs_available_for_user(user_id): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Tabs integration is unavailable for this TPoS.", ) - payload = resp.json() - items = payload.get("data", []) if isinstance(payload, dict) else payload - - # item images are a comma separated string; make a list - for item in items: - images = item.get("images") - item["images"] = from_csv(images) - - def has_allowed_tag(item_tags: str | list[str] | None) -> bool: - # When no tags are configured for this TPoS, show no items - if not tag_list: - return False - item_tag_list = [tag.lower() for tag in inventory_tags_to_list(item_tags)] - return any(tag in item_tag_list for tag in allowed_tags) - - def has_omit_tag(item_omit_tags: str | list[str] | None) -> bool: - if not omit_list: - return False - item_tag_list = [tag.lower() for tag in inventory_tags_to_list(item_omit_tags)] - return any(tag in item_tag_list for tag in omit_list) - - filtered = [ - item - for item in items - if has_allowed_tag(item.get("tags")) and not has_omit_tag(item.get("omit_tags")) - ] - # If no items matched the provided tags, fall back to all items minus omitted ones. - if tag_list and not filtered: - filtered = [item for item in items if not has_omit_tag(item.get("omit_tags"))] - - # hide items with no stock when stock tracking is enabled - return [ - item - for item in filtered - if item.get("quantity_in_stock") is None or item.get("quantity_in_stock") > 0 - ] - - -def inventory_available_for_user(user: User | None) -> bool: - return bool(user and "inventory" in (user.extensions or [])) - - -async def watchonly_available_for_user(user_id: str) -> bool: - installed = await get_installed_extension("watchonly") - if not installed or not installed.active: - return False - active_extensions = await get_user_active_extensions_ids(user_id) - return "watchonly" in active_extensions - - -async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", - headers={"X-API-KEY": api_key}, - ) - resp.raise_for_status() - return resp.json() - - -async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", - headers={"X-API-KEY": api_key}, - params={"network": network}, - ) - resp.raise_for_status() - return resp.json() - - -async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}", - headers={"X-API-KEY": api_key}, - ) - resp.raise_for_status() - return resp.json() - - -async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", - headers={"X-API-KEY": api_key}, - ) - resp.raise_for_status() - return resp.json() - - -def normalize_mempool_endpoint( - mempool_endpoint: str | None, onchain_address: str -) -> str: - endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/") - if "/testnet" in endpoint or "/signet" in endpoint: - return endpoint - if onchain_address.lower().startswith("tb1"): - return f"{endpoint}/testnet" - return endpoint - - -async def fetch_onchain_balance( - mempool_endpoint: str, onchain_address: str -) -> dict[str, Any]: - endpoint = normalize_mempool_endpoint(mempool_endpoint, onchain_address) - async with httpx.AsyncClient() as client: - resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs") - resp.raise_for_status() - data = resp.json() - confirmed_txs = [tx for tx in data if tx["status"]["confirmed"]] - unconfirmed_txs = [tx for tx in data if not tx["status"]["confirmed"]] - return { - "confirmed": sum_transactions(onchain_address, confirmed_txs), - "unconfirmed": sum_transactions(onchain_address, unconfirmed_txs), - "txids": [tx["txid"] for tx in data], - } - - -def sum_outputs(address: str, vouts: list[dict[str, Any]]) -> int: - return sum( - vout["value"] for vout in vouts if vout.get("scriptpubkey_address") == address - ) - - -def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int: - return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs) - - -async def push_order_to_orders( - user_id: str, - payment, - tpos, - base_url: str | None = None, -) -> None: - details = payment.extra.get("details") or {} - payload = { - "source": "tpos", - "tpos_id": payment.extra.get("tpos_id"), - "tpos_name": tpos.name if tpos else None, - "payment_hash": payment.payment_hash, - "checking_id": payment.checking_id, - "amount_msat": payment.amount, - "fee_msat": payment.fee, - "memo": payment.memo, - "paid_in_fiat": bool(payment.extra.get("paid_in_fiat")), - "currency": details.get("currency"), - "exchange_rate": details.get("exchangeRate") - or payment.extra.get("exchangeRate"), - "tax_included": details.get("taxIncluded"), - "tax_value": details.get("taxValue"), - "items": details.get("items") or [], - "notes": payment.extra.get("notes"), - "created_at": payment.time.isoformat() if payment.time else None, - "paid": True, - "shipped": True, - } - - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) - params = {} - if base_url: - params["base_url"] = base_url - async with httpx.AsyncClient() as client: - try: - await client.post( - url=f"http://{settings.host}:{settings.port}/orders/api/v1/orders", - headers={"Authorization": f"Bearer {access}"}, - params=params, - json=payload, - ) - except Exception as exc: - logger.warning(f"tpos: failed to push order to orders: {exc}") + return user_id diff --git a/services_inventory.py b/services_inventory.py new file mode 100644 index 0000000..4dd294d --- /dev/null +++ b/services_inventory.py @@ -0,0 +1,117 @@ +from typing import Any + +import httpx +from lnbits.core.crud import get_wallet +from lnbits.core.models import User +from lnbits.settings import settings + +from .helpers import create_internal_user_access_token, from_csv, inventory_tags_to_list + + +async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: + wallet = await get_wallet(wallet_id) + if not wallet: + return + inventory_id = inventory_payload.get("inventory_id") + items = inventory_payload.get("items") or [] + if not inventory_id or not items: + return + items_to_update = [] + for item in items: + item_id = item.get("id") + qty = item.get("quantity") or 0 + if not item_id or qty <= 0: + continue + items_to_update.append({"id": item_id, "quantity": int(qty)}) + if not items_to_update: + return + + ids = [item["id"] for item in items_to_update] + quantities = [item["quantity"] for item in items_to_update] + + # Needed to accomodate admin users, as using user ID is not possible + access = create_internal_user_access_token(wallet.user) + async with httpx.AsyncClient() as client: + await client.patch( + url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/quantities", + headers={"Authorization": f"Bearer {access}"}, + params={"source": "tpos", "ids": ids, "quantities": quantities}, + ) + return + + +async def get_default_inventory(user_id: str) -> dict[str, Any] | None: + access = create_internal_user_access_token(user_id) + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/inventory/api/v1", + headers={"Authorization": f"Bearer {access}"}, + ) + inventory = resp.json() + if not inventory: + return None + if isinstance(inventory, list): + inventory = inventory[0] if inventory else None + if not isinstance(inventory, dict): + return None + inventory["tags"] = inventory_tags_to_list(inventory.get("tags")) + inventory["omit_tags"] = inventory_tags_to_list(inventory.get("omit_tags")) + return inventory + + +async def get_inventory_items_for_tpos( + user_id: str, + inventory_id: str, + tags: str | list[str] | None, + omit_tags: str | list[str] | None, +) -> list[Any]: + tag_list = inventory_tags_to_list(tags) + omit_list = [tag.lower() for tag in inventory_tags_to_list(omit_tags)] + allowed_tags = [tag.lower() for tag in tag_list] + access = create_internal_user_access_token(user_id) + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/paginated", + headers={"Authorization": f"Bearer {access}"}, + params={"limit": 500, "offset": 0, "is_active": True}, + ) + payload = resp.json() + items = payload.get("data", []) if isinstance(payload, dict) else payload + + # item images are a comma separated string; make a list + for item in items: + images = item.get("images") + item["images"] = from_csv(images) + + def has_allowed_tag(item_tags: str | list[str] | None) -> bool: + # When no tags are configured for this TPoS, show no items + if not tag_list: + return False + item_tag_list = [tag.lower() for tag in inventory_tags_to_list(item_tags)] + return any(tag in item_tag_list for tag in allowed_tags) + + def has_omit_tag(item_omit_tags: str | list[str] | None) -> bool: + if not omit_list: + return False + item_tag_list = [tag.lower() for tag in inventory_tags_to_list(item_omit_tags)] + return any(tag in item_tag_list for tag in omit_list) + + filtered = [ + item + for item in items + if has_allowed_tag(item.get("tags")) and not has_omit_tag(item.get("omit_tags")) + ] + # If no items matched the provided tags, fall back to all items minus omitted ones. + if tag_list and not filtered: + filtered = [item for item in items if not has_omit_tag(item.get("omit_tags"))] + + # hide items with no stock when stock tracking is enabled + return [ + item + for item in filtered + if item.get("quantity_in_stock") is None or item.get("quantity_in_stock") > 0 + ] + + +def inventory_available_for_user(user: User | None) -> bool: + return bool(user and "inventory" in (user.extensions or [])) diff --git a/services_onchain.py b/services_onchain.py new file mode 100644 index 0000000..83b8807 --- /dev/null +++ b/services_onchain.py @@ -0,0 +1,95 @@ +from typing import Any + +import httpx +from lnbits.core.crud import ( + get_installed_extension, + get_user_active_extensions_ids, +) +from lnbits.settings import settings + + +async def watchonly_available_for_user(user_id: str) -> bool: + installed = await get_installed_extension("watchonly") + if not installed or not installed.active: + return False + active_extensions = await get_user_active_extensions_ids(user_id) + return "watchonly" in active_extensions + + +async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", + headers={"X-API-KEY": api_key}, + params={"network": network}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +def normalize_mempool_endpoint( + mempool_endpoint: str | None, onchain_address: str +) -> str: + endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/") + if "/testnet" in endpoint or "/signet" in endpoint: + return endpoint + if onchain_address.lower().startswith("tb1"): + return f"{endpoint}/testnet" + return endpoint + + +async def fetch_onchain_balance( + mempool_endpoint: str, onchain_address: str +) -> dict[str, Any]: + endpoint = normalize_mempool_endpoint(mempool_endpoint, onchain_address) + async with httpx.AsyncClient() as client: + resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs") + resp.raise_for_status() + data = resp.json() + confirmed_txs = [tx for tx in data if tx["status"]["confirmed"]] + unconfirmed_txs = [tx for tx in data if not tx["status"]["confirmed"]] + return { + "confirmed": sum_transactions(onchain_address, confirmed_txs), + "unconfirmed": sum_transactions(onchain_address, unconfirmed_txs), + "txids": [tx["txid"] for tx in data], + } + + +def sum_outputs(address: str, vouts: list[dict[str, Any]]) -> int: + return sum( + vout["value"] for vout in vouts if vout.get("scriptpubkey_address") == address + ) + + +def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int: + return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs) diff --git a/services_orders.py b/services_orders.py new file mode 100644 index 0000000..3ecfb18 --- /dev/null +++ b/services_orders.py @@ -0,0 +1,50 @@ +import httpx +from lnbits.settings import settings +from loguru import logger + +from .helpers import create_internal_user_access_token + + +async def push_order_to_orders( + user_id: str, + payment, + tpos, + base_url: str | None = None, +) -> None: + details = payment.extra.get("details") or {} + payload = { + "source": "tpos", + "tpos_id": payment.extra.get("tpos_id"), + "tpos_name": tpos.name if tpos else None, + "payment_hash": payment.payment_hash, + "checking_id": payment.checking_id, + "amount_msat": payment.amount, + "fee_msat": payment.fee, + "memo": payment.memo, + "paid_in_fiat": bool(payment.extra.get("paid_in_fiat")), + "currency": details.get("currency"), + "exchange_rate": details.get("exchangeRate") + or payment.extra.get("exchangeRate"), + "tax_included": details.get("taxIncluded"), + "tax_value": details.get("taxValue"), + "items": details.get("items") or [], + "notes": payment.extra.get("notes"), + "created_at": payment.time.isoformat() if payment.time else None, + "paid": True, + "shipped": True, + } + + access = create_internal_user_access_token(user_id) + params = {} + if base_url: + params["base_url"] = base_url + async with httpx.AsyncClient() as client: + try: + await client.post( + url=f"http://{settings.host}:{settings.port}/orders/api/v1/orders", + headers={"Authorization": f"Bearer {access}"}, + params=params, + json=payload, + ) + except Exception as exc: + logger.warning(f"tpos: failed to push order to orders: {exc}") diff --git a/services_tabs.py b/services_tabs.py new file mode 100644 index 0000000..507ac82 --- /dev/null +++ b/services_tabs.py @@ -0,0 +1,142 @@ +from http import HTTPStatus +from typing import Any + +import httpx +from fastapi import HTTPException +from lnbits.core.crud import ( + get_installed_extension, + get_user_active_extensions_ids, +) +from lnbits.settings import settings + +from .helpers import create_internal_user_access_token +from .models import Tpos + +_TAB_STATUSES = {"open", "suspended", "closed"} + + +async def tabs_available_for_user(user_id: str) -> bool: + installed = await get_installed_extension("tabs") + if not installed or not installed.active: + return False + active_extensions = await get_user_active_extensions_ids(user_id) + return "tabs" in active_extensions + + +async def fetch_tabs_for_tpos( + user_id: str, + wallet_id: str, + status: str | None = "open", + query: str | None = None, +) -> list[dict[str, Any]]: + if status and status not in _TAB_STATUSES: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid tab status filter.", + ) + payload = await _tabs_request(user_id, "GET", "/tabs") + if not isinstance(payload, list): + return [] + tabs = [tab for tab in payload if tab.get("wallet") == wallet_id] + if status: + tabs = [tab for tab in tabs if tab.get("status") == status] + if query: + needle = query.lower() + tabs = [ + tab + for tab in tabs + if needle in (tab.get("name") or "").lower() + or needle in (tab.get("customer_name") or "").lower() + or needle in (tab.get("reference") or "").lower() + or needle in (tab.get("id") or "").lower() + ] + tabs.sort(key=lambda tab: tab.get("updated_at") or "", reverse=True) + return tabs[:50] + + +async def create_tab_for_tpos(user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return await _tabs_request(user_id, "POST", "/tabs", json=payload) + + +async def create_tab_charge_for_tpos( + user_id: str, + tab_id: str, + payload: dict[str, Any], +) -> dict[str, Any]: + return await _tabs_request(user_id, "POST", f"/tabs/{tab_id}/entries", json=payload) + + +async def fetch_single_tab_for_tpos(user_id: str, tab_id: str) -> dict[str, Any]: + return await _tabs_request(user_id, "GET", f"/tabs/{tab_id}") + + +async def get_tab_for_tpos(user_id: str, tpos: Tpos, tab_id: str) -> dict[str, Any]: + tab = await fetch_single_tab_for_tpos(user_id, tab_id) + if tab.get("wallet") != tpos.wallet: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Tab not found.") + if (tab.get("currency") or "sats").lower() != (tpos.currency or "sats").lower(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Tab currency must match TPoS currency.", + ) + return tab + + +def tab_settlement_tolerance(currency: str | None) -> float: + return 1 if (currency or "sats").lower() == "sats" else 0.01 + + +async def create_tab_settlement_for_tpos( + user_id: str, + tab_id: str, + payload: dict[str, Any], +) -> dict[str, Any]: + return await _tabs_request( + user_id, "POST", f"/tabs/{tab_id}/settlements", json=payload + ) + + +async def _tabs_request( + user_id: str, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, +) -> Any: + access = create_internal_user_access_token(user_id) + try: + async with httpx.AsyncClient() as client: + resp = await client.request( + method, + url=f"http://{settings.host}:{settings.port}/tabs/api/v1{path}", + headers={"Authorization": f"Bearer {access}"}, + json=json, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + raise _raise_tabs_bridge_error(exc) from exc + except httpx.RequestError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail="Tabs service is temporarily unavailable.", + ) from exc + return resp.json() + + +def _raise_tabs_bridge_error(exc: httpx.HTTPStatusError) -> HTTPException: + status_code = exc.response.status_code if exc.response else HTTPStatus.BAD_GATEWAY + if status_code == HTTPStatus.NOT_FOUND: + detail = "Tab not found." + elif status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + detail = "Tabs action not allowed for this TPoS." + elif status_code == HTTPStatus.BAD_REQUEST: + try: + response_detail = exc.response.json().get("detail") + except Exception: + response_detail = None + detail = response_detail or "Invalid tabs request." + else: + detail = "Tabs service is temporarily unavailable." + if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + status_code = HTTPStatus.BAD_GATEWAY + return HTTPException(status_code=status_code, detail=detail) diff --git a/services_wrapper.py b/services_wrapper.py new file mode 100644 index 0000000..9b16e0a --- /dev/null +++ b/services_wrapper.py @@ -0,0 +1,45 @@ +import time + +import httpx +from lnbits.settings import settings +from loguru import logger + +from .crud import get_wrapper_assetlinks_cache, set_wrapper_assetlinks_cache + +WRAPPER_ASSETLINKS_URL = ( + "https://github.com/lnbits/TPoS-Stripe-Tap-to-Pay-Wrapper-Stripev5" + "/releases/latest/download/assetlinks.json" +) +WRAPPER_ASSETLINKS_CACHE_SECONDS = 60 * 60 + + +async def fetch_wrapper_assetlinks() -> dict | list: + now = int(time.time()) + cached = await get_wrapper_assetlinks_cache() + if cached: + cached_assetlinks, cached_at = cached + cache_fresh = now - cached_at < WRAPPER_ASSETLINKS_CACHE_SECONDS + if cache_fresh: + return cached_assetlinks + + try: + async with httpx.AsyncClient( + follow_redirects=True, headers={"User-Agent": settings.user_agent} + ) as client: + resp = await client.get(WRAPPER_ASSETLINKS_URL, timeout=10) + resp.raise_for_status() + assetlinks = resp.json() + except Exception as exc: + if cached: + logger.warning(f"Using cached TPoS wrapper assetlinks.json: {exc!s}") + return cached[0] + raise RuntimeError("Unable to fetch TPoS wrapper assetlinks.json.") from exc + + if not isinstance(assetlinks, (dict, list)): + if cached: + logger.warning("Using cached TPoS wrapper assetlinks.json: invalid JSON") + return cached[0] + raise RuntimeError("TPoS wrapper assetlinks.json is not valid JSON.") + + await set_wrapper_assetlinks_cache(assetlinks, now) + return assetlinks diff --git a/static/components/admin-form-dialog.js b/static/components/admin-form-dialog.js new file mode 100644 index 0000000..4469848 --- /dev/null +++ b/static/components/admin-form-dialog.js @@ -0,0 +1,418 @@ +window.app.component('tpos-admin-form-dialog', { + name: 'tpos-admin-form-dialog', + props: [ + 'dialog', + 'g', + 'currencyOptions', + 'hasFiatProvider', + 'fiatProviders', + 'isFiatCurrency', + 'onchainStatus', + 'onchainWalletOptions', + 'withdrawOptions', + 'createOrUpdateDisabled' + ], + emits: ['close', 'submit'], + template: ` + + + + + + + + + +
+
+ + + + +
+
+ + After saving use the QR code button to create the pairing code. + +
+
+
+
+ +
+
+
+
+ + + currency must be set to fiat + + +
+
+
+
+ + + + + +
+
+
+
+ + + +
+
+ +
+
+ + + If disabled, TPoS waits for the first confirmation before + completing the sale. + + +
+
+
+
+ + + + +
+
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ + + + + + + + Tax Inclusive means the unit price includes tax. (default) +
+ Tax Exclusive means tax is applied on top of the unit price. +
+ +
+
+ + +
+
+ +
+
+
+
+
+
+
+ Update TPoS + Create TPoS + Cancel +
+
+
+
+ ` +}) diff --git a/static/components/admin-import-dialog.js b/static/components/admin-import-dialog.js new file mode 100644 index 0000000..21716e5 --- /dev/null +++ b/static/components/admin-import-dialog.js @@ -0,0 +1,44 @@ +window.app.component('tpos-admin-import-dialog', { + name: 'tpos-admin-import-dialog', + props: ['dialog'], + emits: ['import'], + template: ` + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + +
+ Import + Close +
+
+
+ ` +}) diff --git a/static/components/admin-item-dialog.js b/static/components/admin-item-dialog.js new file mode 100644 index 0000000..a736594 --- /dev/null +++ b/static/components/admin-item-dialog.js @@ -0,0 +1,79 @@ +window.app.component('tpos-admin-item-dialog', { + name: 'tpos-admin-item-dialog', + props: ['dialog', 'categoryList'], + emits: ['close', 'submit'], + template: ` + + + + + + + + + + +
+ + Cancel +
+
+
+
+ ` +}) diff --git a/static/components/admin-share-dialog.js b/static/components/admin-share-dialog.js new file mode 100644 index 0000000..ad9e4bc --- /dev/null +++ b/static/components/admin-share-dialog.js @@ -0,0 +1,83 @@ +window.app.component('tpos-admin-share-dialog', { + name: 'tpos-admin-share-dialog', + props: ['dialog', 'buildShareUrl'], + emits: ['warm-wrapper-assetlinks', 'generate-wrapper-token', 'copy-url'], + template: ` + + + +
+

+ +

+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+ + If accepting Stripe payments, visit + https://dashboard.stripe.com/terminal and grab a new location ID + +
+
+
+
+ +
+
+ Press if accepting Stripe payments. +
+
+
+
+ Copy URL + Close +
+
+
+ ` +}) diff --git a/static/components/held-carts-dialog.js b/static/components/held-carts-dialog.js new file mode 100644 index 0000000..0d69f01 --- /dev/null +++ b/static/components/held-carts-dialog.js @@ -0,0 +1,42 @@ +window.app.component('tpos-held-carts-dialog', { + name: 'tpos-held-carts-dialog', + props: ['show', 'heldCarts', 'formatDate'], + emits: ['update:show', 'restore', 'delete'], + template: ` + + + + + + + + + + + + + + + + + + + + + ` +}) diff --git a/static/components/print-dialog.js b/static/components/print-dialog.js new file mode 100644 index 0000000..8b7aa9b --- /dev/null +++ b/static/components/print-dialog.js @@ -0,0 +1,41 @@ +window.app.component('tpos-print-dialog', { + name: 'tpos-print-dialog', + props: ['show', 'paymentHash'], + emits: ['update:show', 'close', 'print-order-receipt', 'print-receipt'], + template: ` + + + +
Print Receipt
+
+ +
+ + Print order receipt + + + Print receipt + +
+ CLOSE +
+
+
+ ` +}) diff --git a/static/components/receipt.js b/static/components/receipt.js index 3e7f3dd..2f6a1a1 100644 --- a/static/components/receipt.js +++ b/static/components/receipt.js @@ -67,15 +67,14 @@ window.app.component('receipt', { return amount / this.exchangeRate }, formatCurrencyAmount(amount) { - return roundTposCurrencyAmount(amount, this.currency).toFixed( - getTposCurrencyFractionDigits(this.currency) - ) + return window.tposUtils + .roundTposCurrencyAmount(amount, this.currency) + .toFixed(window.tposUtils.getTposCurrencyFractionDigits(this.currency)) } }, created() { this.currency = this.data.extra.details.currency || g.settings.denomination this.exchangeRate = this.data.extra.details.exchangeRate || 1 - console.log('Receipt component created', this.data) }, template: `
diff --git a/static/js/index.js b/static/js/index.js index a9547e2..9d973ce 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,26 +1,4 @@ -const getTposCurrencyFractionDigits = currency => { - const code = (currency || '').toUpperCase() - if (code === 'SAT' || code === 'SATS') { - return 0 - } - try { - return new Intl.NumberFormat(window.i18n.global.locale, { - style: 'currency', - currency: code - }).resolvedOptions().maximumFractionDigits - } catch (e) { - return 2 - } -} - -const roundTposCurrencyAmount = (amount, currency) => { - const value = Number(amount) || 0 - if ((currency || '').toLowerCase() === 'sats') { - return Math.ceil(value) - } - const scale = 10 ** getTposCurrencyFractionDigits(currency) - return Math.round(value * scale) / scale -} +const {roundTposCurrencyAmount} = window.tposUtils const mapTpos = obj => { obj.date = Quasar.date.formatDate( @@ -51,6 +29,8 @@ const mapTpos = obj => { obj.onchain_enabled = Boolean(obj.onchain_enabled) obj.onchain_wallet_id = obj.onchain_wallet_id || null obj.onchain_zero_conf = obj.onchain_zero_conf ?? true + obj.tabs_enabled = Boolean(obj.tabs_enabled) + obj.tabs_allow_create = Boolean(obj.tabs_allow_create) obj.useWrapper = false obj.posLocation = '' obj.auth = '' @@ -156,7 +136,9 @@ window.app = Vue.createApp({ allow_cash_settlement: false, onchain_enabled: false, onchain_wallet_id: null, - onchain_zero_conf: true + onchain_zero_conf: true, + tabs_enabled: false, + tabs_allow_create: false }, advanced: { tips: false, @@ -312,7 +294,9 @@ window.app = Vue.createApp({ allow_cash_settlement: false, onchain_enabled: false, onchain_wallet_id: null, - onchain_zero_conf: true + onchain_zero_conf: true, + tabs_enabled: false, + tabs_allow_create: false } this.formDialog.advanced = {tips: false, otc: false} }, @@ -405,6 +389,9 @@ window.app = Vue.createApp({ data.onchain_wallet_id = null data.onchain_zero_conf = true } + if (!data.tabs_enabled) { + data.tabs_allow_create = false + } const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) diff --git a/static/js/tpos-utils.js b/static/js/tpos-utils.js new file mode 100644 index 0000000..a3d5424 --- /dev/null +++ b/static/js/tpos-utils.js @@ -0,0 +1,29 @@ +window.tposUtils = { + getTposCurrencyFractionDigits(currency) { + const code = (currency || '').toUpperCase() + if (code === 'SAT' || code === 'SATS') { + return 0 + } + try { + return new Intl.NumberFormat(window.i18n.global.locale, { + style: 'currency', + currency: code + }).resolvedOptions().maximumFractionDigits + } catch (e) { + return 2 + } + }, + + getTposCurrencyScale(currency) { + return 10 ** window.tposUtils.getTposCurrencyFractionDigits(currency) + }, + + roundTposCurrencyAmount(amount, currency) { + const value = Number(amount) || 0 + if ((currency || '').toLowerCase() === 'sats') { + return Math.ceil(value) + } + const scale = window.tposUtils.getTposCurrencyScale(currency) + return Math.round(value * scale) / scale + } +} diff --git a/static/js/tpos.js b/static/js/tpos.js index 8da252d..2061a4b 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -1,38 +1,8 @@ -const getTposCurrencyFractionDigits = currency => { - const code = (currency || '').toUpperCase() - if (code === 'SAT' || code === 'SATS') { - return 0 - } - try { - return new Intl.NumberFormat(window.i18n.global.locale, { - style: 'currency', - currency: code - }).resolvedOptions().maximumFractionDigits - } catch (e) { - return 2 - } -} - -const getTposCurrencyScale = currency => - 10 ** getTposCurrencyFractionDigits(currency) - -const roundTposCurrencyAmount = (amount, currency) => { - const value = Number(amount) || 0 - if ((currency || '').toLowerCase() === 'sats') { - return Math.ceil(value) - } - const scale = getTposCurrencyScale(currency) - return Math.round(value * scale) / scale -} - -const amountToTposStack = (amount, currency) => { - const value = Math.max(0, Number(amount) || 0) - if ((currency || '').toLowerCase() === 'sats') { - return Array.from(String(Math.ceil(value)), Number) - } - const scale = getTposCurrencyScale(currency) - return Array.from(String(Math.round(value * scale)), Number) -} +const { + getTposCurrencyFractionDigits, + getTposCurrencyScale, + roundTposCurrencyAmount +} = window.tposUtils window.app = Vue.createApp({ el: '#vue', @@ -46,6 +16,8 @@ window.app = Vue.createApp({ allowPriceAdjustment: true, allowCashSettlement: false, onchainEnabled: false, + tabsEnabled: false, + tabsAllowCreate: false, payInFiat: false, fiatMethod: 'checkout', atmPremium: tpos.withdraw_premium / 100, @@ -88,6 +60,24 @@ window.app = Vue.createApp({ paymentChecker: null, internalMemo: null }, + tabsDialog: { + show: false, + loading: false, + creating: false, + posting: false, + settling: false, + mode: 'charge', + tabs: [], + selectedTabId: null, + query: '', + createMode: false, + newTab: { + name: '', + customer_name: '', + reference: '' + } + }, + pendingTabSettlement: null, cashValidating: false, tipDialog: { show: false @@ -259,6 +249,9 @@ window.app = Vue.createApp({ this.currency ) }, + isSettlingTab() { + return Boolean(this.pendingTabSettlement) + }, tipAmountSat() { if (!this.exchangeRate) return 0 return Math.ceil(this.tipAmount * this.exchangeRate) @@ -387,7 +380,9 @@ window.app = Vue.createApp({ if (payload.type !== 'invoice_created') return if (!payload.payment_hash || !payload.payment_request) return this.amount = payload.amount_fiat || this.amount - this.tipAmount = payload.tip_amount || this.tipAmount + this.tipAmount = payload.amount_fiat + ? payload.tip_amount_fiat || this.tipAmount + : payload.tip_amount || this.tipAmount this.exchangeRate = payload.exchange_rate || this.exchangeRate this.openInvoiceDialog(payload) @@ -650,6 +645,7 @@ window.app = Vue.createApp({ this.total = 0.0 this.addedAmount = 0.0 this.resetPaymentAttempt() + this.pendingTabSettlement = null if (this.$q.screen.lt.md) { this.cartDrawer = false } @@ -940,7 +936,7 @@ window.app = Vue.createApp({ const paymentAmount = this.total > 0.0 ? roundTposCurrencyAmount(this.total + this.amount, this.currency) - : this.amount + : roundTposCurrencyAmount(this.amount, this.currency) this.paymentAmount = paymentAmount this.sat = Math.ceil(paymentAmount * this.exchangeRate) @@ -994,11 +990,230 @@ window.app = Vue.createApp({ case 'btc_onchain': this.fiatMethod = 'checkout' break + case 'tab': + this.fiatMethod = 'checkout' + break } this._currencyResolver(method) this._currencyResolver = null } }, + normalizeApiAmount(currency, value) { + if (value === null || value === undefined || value === '') return null + const parsed = Number(value) + if (Number.isNaN(parsed)) return null + if ((currency || '').toLowerCase() === 'sats') return Math.round(parsed) + return roundTposCurrencyAmount(parsed, currency) + }, + mapTabFromApi(tab) { + const currency = tab?.currency || this.currency || 'sats' + return { + ...tab, + currency, + balance: this.normalizeApiAmount(currency, tab?.balance) ?? 0 + } + }, + generateTabsIdempotencyKey(prefix) { + const randomSuffix = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : Date.now().toString() + return `${prefix}:${this.tposId}:${randomSuffix}` + }, + resetTabsDialogNewTab() { + this.tabsDialog.newTab = { + name: '', + customer_name: '', + reference: '' + } + }, + buildTabChargeParams() { + const paymentAmount = + this.paymentAmount !== null ? this.paymentAmount : this.amount + const notes = {} + const items = this.cart.size + ? [...this.cart.values()].map(item => { + if (item.note) { + notes[item.title] = item.note + } + return { + id: item.id, + price: item.price, + formattedPrice: item.formattedPrice, + quantity: item.quantity, + title: item.title, + tax: item.tax || this.taxDefault, + note: item.note || null + } + }) + : [] + const normalizedAmount = + this.currency === 'sats' + ? Math.ceil(paymentAmount) + : roundTposCurrencyAmount(paymentAmount, this.currency) + + return { + amount: normalizedAmount, + description: this.invoiceDialog.internalMemo || 'TPoS order charge', + items, + notes: Object.keys(notes).length ? notes : null, + internal_memo: this.invoiceDialog.internalMemo || null, + idempotency_key: this.generateTabsIdempotencyKey('tpos') + } + }, + buildTabSettlementParams() { + const selectedTab = this.tabsDialog.tabs.find( + tab => tab.id === this.tabsDialog.selectedTabId + ) + const amount = this.normalizeApiAmount( + selectedTab?.currency || this.currency, + selectedTab?.balance + ) + return { + tab_id: this.tabsDialog.selectedTabId, + amount, + description: this.invoiceDialog.internalMemo || 'TPoS settlement', + reference: `tpos-${this.tposId}`, + idempotency_key: this.generateTabsIdempotencyKey('tpos:settlement') + } + }, + closeTabsDialog() { + this.tabsDialog.show = false + this.tabsDialog.createMode = false + this.tabsDialog.posting = false + this.tabsDialog.settling = false + this.tabsDialog.mode = 'charge' + this.tabsDialog.query = '' + this.resetTabsDialogNewTab() + }, + async loadTabsForCharge() { + this.tabsDialog.loading = true + try { + const query = this.tabsDialog.query + ? `&q=${encodeURIComponent(this.tabsDialog.query)}` + : '' + const {data} = await LNbits.api.request( + 'GET', + `/tpos/api/v1/tposs/${this.tposId}/tabs?status=open${query}` + ) + this.tabsDialog.tabs = (data.data || []).map(tab => + this.mapTabFromApi(tab) + ) + if (!this.tabsDialog.tabs.length) { + this.tabsDialog.selectedTabId = null + this.tabsDialog.createMode = this.tabsAllowCreate + return + } + if ( + !this.tabsDialog.selectedTabId || + !this.tabsDialog.tabs.find( + tab => tab.id === this.tabsDialog.selectedTabId + ) + ) { + this.tabsDialog.selectedTabId = this.tabsDialog.tabs[0].id + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.tabsDialog.loading = false + } + }, + async createTabFromDialog() { + if (!this.tabsDialog.newTab.name || this.tabsDialog.creating) return + this.tabsDialog.creating = true + try { + const payload = { + name: this.tabsDialog.newTab.name, + customer_name: this.tabsDialog.newTab.customer_name || null, + reference: this.tabsDialog.newTab.reference || null, + currency: this.currency + } + const {data} = await LNbits.api.request( + 'POST', + `/tpos/api/v1/tposs/${this.tposId}/tabs`, + null, + payload + ) + this.tabsDialog.createMode = false + this.resetTabsDialogNewTab() + await this.loadTabsForCharge() + this.tabsDialog.selectedTabId = data.id + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.tabsDialog.creating = false + } + }, + async submitTabCharge() { + if (!this.tabsDialog.selectedTabId || this.tabsDialog.posting) return + this.tabsDialog.posting = true + try { + const payload = this.buildTabChargeParams() + const {data} = await LNbits.api.request( + 'POST', + `/tpos/api/v1/tposs/${this.tposId}/tabs/${this.tabsDialog.selectedTabId}/charges`, + null, + payload + ) + this.closeTabsDialog() + this.clearCart() + this.stack = [] + this.amount = 0.0 + this.showComplete() + Quasar.Notify.create({ + type: 'positive', + message: `Added to tab: ${data.tab?.name || data.tab_id}` + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.tabsDialog.posting = false + } + }, + async submitTabSettlement() { + if (!this.tabsDialog.selectedTabId || this.tabsDialog.settling) return + this.tabsDialog.settling = true + try { + const payload = this.buildTabSettlementParams() + if (!payload.amount) { + Quasar.Notify.create({ + type: 'warning', + message: 'This tab has no outstanding balance to settle.' + }) + return + } + this.closeTabsDialog() + this.pendingTabSettlement = payload + this.paymentAmount = payload.amount + this.sat = Math.ceil(payload.amount * this.exchangeRate) + if (!this.exchangeRate || this.exchangeRate == 0 || this.sat == 0) { + this.resetPaymentAttempt() + this.pendingTabSettlement = null + Quasar.Notify.create({ + type: 'negative', + message: + 'Exchange rate not available, or wrong value. Please try again later.' + }) + return + } + await this.showInvoice() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.tabsDialog.settling = false + } + }, + async openTabChargeDialog() { + await this.openTabsDialog('charge') + }, + async openTabSettlementDialog() { + await this.openTabsDialog('settlement') + }, + async openTabsDialog(mode) { + this.tabsDialog.mode = mode + await this.loadTabsForCharge() + this.tabsDialog.show = true + }, buildInvoiceParams() { const paymentAmount = this.paymentAmount !== null ? this.paymentAmount : this.amount @@ -1048,6 +1263,9 @@ window.app = Vue.createApp({ if (this.lnaddress) { params.user_lnaddress = this.lnaddressDialog.lnaddress } + if (this.pendingTabSettlement) { + params.tab_settlement = this.pendingTabSettlement + } if (this.usingInventory && this.cart.size) { params.inventory = { inventory_id: this.inventoryId, @@ -1069,9 +1287,15 @@ window.app = Vue.createApp({ if ( this.fiatProvider || this.allowCashSettlement || - this.onchainEnabled + this.onchainEnabled || + this.isSettlingTab || + (this.tabsEnabled && !this.isSettlingTab) ) { const method = await this.showPaymentMethod() + if (method === 'tab') { + await this.openTabChargeDialog() + return + } this.payInFiat = method === 'fiat' this.invoiceDialog.data.payment_method = method } else { @@ -1331,6 +1555,7 @@ window.app = Vue.createApp({ }) .catch(error => { console.error(error) + LNbits.utils.notifyApiError(error) }) }, async getRates() { @@ -1546,7 +1771,6 @@ window.app = Vue.createApp({ this.printText = data.print_text || '' this.orderReceipt = false - console.log('Printing receipt for payment hash:', paymentHash) await this.$nextTick() window.print() } catch (error) { @@ -1571,7 +1795,6 @@ window.app = Vue.createApp({ this.printText = data.order_print_text || '' this.orderReceipt = true - console.log('Printing order receipt for payment hash:', paymentHash) await this.$nextTick() window.print() } catch (error) { @@ -1653,6 +1876,8 @@ window.app = Vue.createApp({ this.allowPriceAdjustment = tpos.allow_price_adjustment ?? true this.allowCashSettlement = Boolean(tpos.allow_cash_settlement) this.onchainEnabled = Boolean(tpos.onchain_enabled) + this.tabsEnabled = Boolean(tpos.tabs_enabled) + this.tabsAllowCreate = Boolean(tpos.tabs_allow_create) this.tip_options = tpos.tip_options == 'null' ? null : tpos.tip_options @@ -1689,7 +1914,7 @@ window.app = Vue.createApp({ this.disconnectRemoteInvoiceWS() Object.values(this.paymentWsByHash).forEach(ws => ws.close()) }, - onMounted() { + mounted() { if (!this.headerElement) { this.headerElement = document.querySelector('.q-header') } diff --git a/tasks.py b/tasks.py index 47ad857..bf27e42 100644 --- a/tasks.py +++ b/tasks.py @@ -3,7 +3,7 @@ from lnbits.core.crud import get_user_active_extensions_ids, get_wallet from lnbits.core.crud.payments import get_standalone_payment, update_payment -from lnbits.core.models import Payment +from lnbits.core.models import Payment, PaymentState from lnbits.core.services import ( create_invoice, get_pr_from_lnurl, @@ -19,11 +19,11 @@ get_tpos_payment_by_hash, update_tpos_payment, ) -from .services import ( - deduct_inventory_stock, - fetch_onchain_balance, - push_order_to_orders, -) +from .services import ensure_tpos_tabs_access +from .services_inventory import deduct_inventory_stock +from .services_onchain import fetch_onchain_balance +from .services_orders import push_order_to_orders +from .services_tabs import create_tab_settlement_for_tpos async def wait_for_paid_invoices(): @@ -58,10 +58,10 @@ async def poll_onchain_payments(): ) tpos_payment.balance = settled_balance tpos_payment.pending = unconfirmed_balance - if settled_balance >= tpos_payment.amount: - tpos_payment.paid = True + settled = settled_balance >= tpos_payment.amount + if settled: tpos_payment.payment_method = "onchain" - if changed or tpos_payment.paid: + if changed or settled: await update_tpos_payment(tpos_payment) await websocket_updater( tpos_payment.payment_hash, @@ -75,7 +75,7 @@ async def poll_onchain_payments(): } ), ) - if tpos_payment.paid: + if settled: await settle_onchain_tpos_payment(tpos_payment) except Exception as exc: logger.warning(f"tpos: onchain polling failed: {exc}") @@ -108,12 +108,11 @@ async def settle_onchain_tpos_payment(tpos_payment) -> None: if not payment or not payment.extra or payment.extra.get("tag") != "tpos": return - if payment.success: - return - payment.extra["payment_method"] = "onchain" payment.extra["settled_by_onchain"] = True - await update_payment(payment) + if not payment.success: + payment.status = PaymentState.SUCCESS + await update_payment(payment) await internal_invoice_queue_put(payment.checking_id) @@ -152,7 +151,7 @@ async def process_paid_tpos_payment( address = payment.extra.get("lnaddress") if address: try: - pr = await get_pr_from_lnurl(address, int(calc_amount)) + pr = await get_pr_from_lnurl(address, int(calc_amount // 1000) * 1000) except Exception as exc: logger.error(f"tpos: Error getting payment request from lnurl: {exc}") pr = None @@ -169,6 +168,7 @@ async def process_paid_tpos_payment( await websocket_updater(tpos_id, json.dumps(stripped_payment)) await websocket_updater(payment.payment_hash, json.dumps(stripped_payment)) + await maybe_settle_tab(payment, tpos, payment_method) await maybe_push_order(payment, tpos) inventory_payload = payment.extra.get("inventory") @@ -201,6 +201,45 @@ async def process_paid_tpos_payment( logger.debug(f"tpos: tip invoice paid: {paid_payment.checking_id}") +async def maybe_settle_tab(payment: Payment, tpos, payment_method: str) -> None: + settlement = (payment.extra or {}).get("tab_settlement") + if not settlement: + return + + try: + user_id = await ensure_tpos_tabs_access(tpos) + await create_tab_settlement_for_tpos( + user_id=user_id, + tab_id=settlement["tab_id"], + payload={ + "amount": settlement["amount"], + "method": _tabs_settlement_method(payment_method, payment), + "reference": settlement.get("reference"), + "description": settlement.get("description") or "TPoS settlement", + "metadata": json.dumps( + { + "source": "tpos", + "source_id": tpos.id, + "source_action": "settlement_paid", + "payment_hash": payment.payment_hash, + "payment_method": payment_method, + } + ), + "idempotency_key": settlement["idempotency_key"], + }, + ) + except Exception as exc: + logger.warning(f"tpos: tab settlement failed: {exc}") + + +def _tabs_settlement_method(payment_method: str, payment: Payment) -> str: + if payment_method == "cash": + return "cash" + if payment.extra.get("fiat_method") == "terminal": + return "card" + return "other" + + def _payment_method(payment: Payment) -> str: if payment.extra.get("payment_method"): return str(payment.extra["payment_method"]) diff --git a/templates/tpos/_options_fab.html b/templates/tpos/_options_fab.html index 0ec8459..b70c5e8 100644 --- a/templates/tpos/_options_fab.html +++ b/templates/tpos/_options_fab.html @@ -48,6 +48,15 @@ label-position="left" :label="showPoS ? 'Cart View' : 'PoS View'" > + - - - -
Print Receipt
-
- -
- - Print order receipt - - - Print receipt - -
- CLOSE -
-
-
+ @@ -290,37 +268,13 @@
- - - - - - - - - - - - - - - - - - - - + @@ -461,7 +415,140 @@
+
+ +
+ + Tab +
+
+
+ + + + +
+ + +
+ + + + + + + + + + + + +
+
+ Latest updated open tabs +
+ + + + + + + + +
+
No open tabs found.
+ +
+ +
+ +
+ + + + +
+
+ + + + + + +
+
diff --git a/templates/tpos/index.html b/templates/tpos/index.html index 8f18dd7..e9bd14f 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -362,577 +362,43 @@
{{SITE_TITLE}} TPoS extension
- - - - - - - - - -
-
- - - - -
-
- - After saving use the QR code button to create the pairing code. - -
-
-
-
- -
-
-
-
- - - currency must be set to fiat - - -
-
-
-
- - - - - -
-
-
-
- - - -
-
- -
-
- - - If disabled, TPoS waits for the first confirmation before - completing the sale. - - -
-
-
-
- - - - -
-
- - - -
-
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
- -
-
- - - - - - - - Tax Inclusive means the unit price includes tax. (default) -
- Tax Exclusive means tax is applied on top of the unit price. -
- -
-
- - -
-
- -
-
-
-
-
-
-
- Update TPoS - Create TPoS - Cancel -
-
-
-
- - - - - - - - - - -
- - Cancel -
-
-
-
- - - -
-

- -

-
-
-
- -
-
-
-
- -
-
-
-
-
- - -
-
- - If accepting Stripe payments, visit - https://dashboard.stripe.com/terminal and grab a new location ID - -
-
-
-
- -
-
- Press if accepting Stripe payments. -
-
-
-
- Copy URL - Close -
-
-
- - - -
- -
-
- - - - - - - - - - - - - - - - - - -
- Import - Close -
-
-
+ + + + {% endblock %} {% block scripts %} {{ window_vars(user) }} + + + + + {% endblock %} diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html index 584671d..0bb560a 100644 --- a/templates/tpos/tpos.html +++ b/templates/tpos/tpos.html @@ -241,10 +241,13 @@
const lnaddress_cut = tpos.lnaddress_cut + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..832d3cf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +import os +from typing import Any, cast + +import httpx +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] +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(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..93ef75f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,971 @@ +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.crud.payments import create_payment, update_payment_checking_id +from lnbits.core.crud.wallets import create_wallet +from lnbits.core.models import CreateInvoice, CreatePayment, PaymentState +from lnbits.core.models.users import Account +from lnbits.core.services import ( + create_payment_request, + 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, +) + +import tpos.tasks as tpos_tasks # type: ignore[import] +import tpos.views_api as views_api # type: ignore[import] +import tpos.views_atm as views_atm # type: ignore[import] +import tpos.views_inventory as views_inventory # type: ignore[import] +import tpos.views_lnurl as views_lnurl # type: ignore[import] +import tpos.views_onchain as views_onchain # type: ignore[import] +import tpos.views_payments as views_payments # type: ignore[import] +import tpos.views_wrapper as views_wrapper # type: ignore[import] +from tpos.crud import ( # type: ignore[import] + create_tpos_payment, + get_tpos, + get_tpos_payment_by_hash, + update_tpos, +) +from tpos.models import TposPayment # type: ignore[import] +from tpos.tasks import ( # type: ignore[import] + on_invoice_paid, + settle_onchain_tpos_payment, +) + + +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_tpos_tabs_reject_foreign_wallet_tab(client: AsyncClient): + user, wallet = await _user_with_tabs("tabswalletuser") + second_wallet = await create_wallet(user_id=user.id) + + first_tpos = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(tabs_enabled=True, tabs_allow_create=True), + headers={"X-API-KEY": wallet.adminkey}, + ) + second_tpos = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(tabs_enabled=True, tabs_allow_create=True), + headers={"X-API-KEY": second_wallet.adminkey}, + ) + assert first_tpos.status_code == second_tpos.status_code == 201 + + foreign_tab = await client.post( + f"/tpos/api/v1/tposs/{second_tpos.json()['id']}/tabs", + json={"name": "Other wallet"}, + ) + assert foreign_tab.status_code == 200 + + charge = await client.post( + f"/tpos/api/v1/tposs/{first_tpos.json()['id']}/tabs/{foreign_tab.json()['id']}/charges", + json={"amount": 1, "idempotency_key": "foreign-tab-charge"}, + ) + assert charge.status_code == 404 + + settlement = await client.post( + f"/tpos/api/v1/tposs/{first_tpos.json()['id']}/invoices", + json={ + "amount": 1, + "tab_settlement": { + "tab_id": foreign_tab.json()["id"], + "amount": 1, + "idempotency_key": "foreign-tab-settlement", + }, + }, + ) + assert settlement.status_code == 404 + + +@pytest.mark.asyncio +async def test_tabs_bridge_returns_tabs_error_detail(client: AsyncClient): + _user, wallet = await _user_with_tabs("tabserroruser") + 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", + "limit_type": "hard", + "limit_amount": 100, + }, + ) + assert create_tab.status_code == 200 + tab = create_tab.json() + + charge = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/tabs/{tab['id']}/charges", + json={ + "amount": 101, + "description": "Over limit", + "idempotency_key": "tpos-charge-over-limit", + }, + ) + assert charge.status_code == 400 + assert charge.json()["detail"] == "Charge would exceed the configured tab limit." + + +@pytest.mark.asyncio +async def test_paid_tpos_invoice_settles_tab_via_real_tabs_api( + client: AsyncClient, monkeypatch +): + 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 + paid_messages = [] + + async def fake_paid_websocket(channel, message): + paid_messages.append((channel, json.loads(message))) + + monkeypatch.setattr(tpos_tasks, "websocket_updater", fake_paid_websocket) + await on_invoice_paid(payment) + + assert {channel for channel, _message in paid_messages} >= { + tpos["id"], + invoice["payment_hash"], + } + assert all(message["pending"] is False for _channel, message in paid_messages) + assert all( + message["payment_method"] == "lightning" for _channel, message in paid_messages + ) + + 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_lnaddress_forwarding_uses_whole_sat_amount( + client: AsyncClient, monkeypatch +): + _user, wallet = await _user_with_tabs("lnaddressuser") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(lnaddress=True, lnaddress_cut=0), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + payment_hash = uuid4().hex + payment = await create_payment( + f"checking-{payment_hash}", + CreatePayment( + wallet_id=wallet.id, + payment_hash=payment_hash, + bolt11="bolt11", + amount_msat=395_920, + memo="lnaddress sale", + extra={ + "tag": "tpos", + "tpos_id": tpos["id"], + "lnaddress": "user@example.com", + }, + ), + status=PaymentState.SUCCESS, + ) + requested_amounts = [] + + async def fake_get_pr_from_lnurl(address, amount): + assert address == "user@example.com" + requested_amounts.append(amount) + return "bolt11-forward" + + async def fake_pay_invoice(**_kwargs): + return payment + + async def fake_websocket_updater(*_args): + return None + + monkeypatch.setattr(tpos_tasks, "get_pr_from_lnurl", fake_get_pr_from_lnurl) + monkeypatch.setattr(tpos_tasks, "pay_invoice", fake_pay_invoice) + monkeypatch.setattr(tpos_tasks, "websocket_updater", fake_websocket_updater) + + await on_invoice_paid(payment) + + assert requested_amounts == [395_000] + + +@pytest.mark.asyncio +async def test_remote_invoice_payload_keeps_fiat_tip_amount( + client: AsyncClient, monkeypatch +): + _user, wallet = await _user_with_tabs("remotetipuser") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(currency="USD", enable_remote=True), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + sent_messages = [] + + async def fake_websocket_updater(channel, message): + sent_messages.append((channel, json.loads(message))) + + monkeypatch.setattr(views_payments, "websocket_updater", fake_websocket_updater) + invoice_response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={ + "amount": 3700, + "tip_amount": 3700, + "amount_fiat": 0.46, + "tip_amount_fiat": 0.02, + "exchange_rate": 80000, + "memo": "$0.46 with 5% tip", + "pay_in_fiat": False, + }, + ) + + assert invoice_response.status_code == 201 + assert sent_messages[0][1]["tip_amount"] == 3700 + assert sent_messages[0][1]["tip_amount_fiat"] == 0.02 + + +@pytest.mark.asyncio +async def test_onchain_invoice_option_creates_internal_payment( + client: AsyncClient, monkeypatch +): + user, wallet = await _user_with_tabs("onchainuser") + settings.super_user = user.id + headers = {"X-API-KEY": wallet.adminkey} + + async def fake_watchonly_settings(**kwargs): + return {"mempool_endpoint": "https://mempool.example"} + + async def fake_onchain_address(inkey, wallet_id): + assert wallet_id == "watch-wallet" + return {"address": "bc1qtposaddress"} + + monkeypatch.setattr( + views_api, "_validate_watchonly_settings", fake_watchonly_settings + ) + monkeypatch.setattr( + views_payments, "_validate_watchonly_settings", fake_watchonly_settings + ) + monkeypatch.setattr(views_payments, "fetch_onchain_address", fake_onchain_address) + + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(onchain_enabled=True, onchain_wallet_id="watch-wallet"), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + + invoice_response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={"amount": 42, "memo": "Onchain", "payment_method": "btc_onchain"}, + ) + assert invoice_response.status_code == 201 + invoice = invoice_response.json() + assert invoice["payment_request"] == "bc1qtposaddress" + assert invoice["payment_options"] == ["btc", "btc_onchain"] + assert invoice["payment_method"] == "onchain" + + payment = await get_standalone_payment(invoice["payment_hash"], incoming=True) + assert payment is not None + assert payment.is_internal + assert payment.checking_id.startswith("internal_onchain_") + + tpos_payment = await get_tpos_payment_by_hash(invoice["payment_hash"]) + assert tpos_payment is not None + assert tpos_payment.onchain_address == "bc1qtposaddress" + assert tpos_payment.mempool_endpoint == "https://mempool.example" + + queued_checking_ids = [] + + async def fake_internal_invoice_queue_put(checking_id): + queued_checking_ids.append(checking_id) + + monkeypatch.setattr( + tpos_tasks, "internal_invoice_queue_put", fake_internal_invoice_queue_put + ) + await settle_onchain_tpos_payment(tpos_payment) + settled_payment = await get_standalone_payment( + invoice["payment_hash"], incoming=True + ) + assert settled_payment is not None + assert settled_payment.success is True + + await settle_onchain_tpos_payment(tpos_payment) + assert queued_checking_ids == [payment.checking_id, payment.checking_id] + + +@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 + + +@pytest.mark.asyncio +async def test_wrapper_inventory_onchain_status_endpoints( + client: AsyncClient, monkeypatch +): + user, wallet = await _user_with_tabs("statususer") + headers = {"X-API-KEY": wallet.adminkey} + + async def fake_assetlinks(): + return [{"relation": ["delegate_permission/common.handle_all_urls"]}] + + monkeypatch.setattr(views_wrapper, "fetch_wrapper_assetlinks", fake_assetlinks) + assetlinks = await client.get("/tpos/api/v1/well-known/assetlinks.json") + assert assetlinks.status_code == 200 + assert assetlinks.json()[0]["relation"] == [ + "delegate_permission/common.handle_all_urls" + ] + + monkeypatch.setattr( + views_inventory, "inventory_available_for_user", lambda user: False + ) + inventory_disabled = await client.get( + "/tpos/api/v1/inventory/status", headers=headers + ) + assert inventory_disabled.status_code == 200 + assert inventory_disabled.json() == { + "enabled": False, + "inventory_id": None, + "tags": [], + "omit_tags": [], + } + + async def fake_default_inventory(user_id): + assert user_id == user.id + return {"id": "inv1", "tags": "coffee,tea", "omit_tags": "hidden"} + + monkeypatch.setattr( + views_inventory, "inventory_available_for_user", lambda user: True + ) + monkeypatch.setattr( + views_inventory, "get_default_inventory", fake_default_inventory + ) + inventory_enabled = await client.get( + "/tpos/api/v1/inventory/status", headers=headers + ) + assert inventory_enabled.status_code == 200 + assert inventory_enabled.json() == { + "enabled": True, + "inventory_id": "inv1", + "tags": ["coffee", "tea"], + "omit_tags": ["hidden"], + } + + async def fake_watchonly_status(wallet): + return False + + monkeypatch.setattr( + views_onchain, "watchonly_available_for_user", fake_watchonly_status + ) + onchain = await client.get("/tpos/api/v1/onchain/status", headers=headers) + assert onchain.status_code == 200 + assert onchain.json()["available"] is False + + +@pytest.mark.asyncio +async def test_inventory_items_and_lnaddress_check(client: AsyncClient, monkeypatch): + _user, wallet = await _user_with_tabs("inventoryuser") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", json=_tpos_payload(), headers=headers + ) + assert create.status_code == 201 + tpos = await get_tpos(create.json()["id"]) + assert tpos is not None + tpos.use_inventory = True + tpos.inventory_id = "inv1" + tpos.inventory_tags = "coffee" + tpos.inventory_omit_tags = "hidden" + await update_tpos(tpos) + + async def unexpected_default_inventory(_user_id): + raise AssertionError( + "Configured inventory must not fetch the default inventory." + ) + + monkeypatch.setattr( + views_inventory, "get_default_inventory", unexpected_default_inventory + ) + + async def fake_inventory_items(user_id, inventory_id, tags, omit_tags): + assert inventory_id == "inv1" + assert tags == "coffee" + assert omit_tags == "hidden" + return [ + { + "id": "item1", + "name": "Coffee", + "description": "Hot", + "price": 250, + "tax_rate": 10, + "images": ["https://example.com/coffee.png"], + "tags": "coffee", + "quantity_in_stock": 3, + "is_active": True, + } + ] + + monkeypatch.setattr( + views_inventory, "get_inventory_items_for_tpos", fake_inventory_items + ) + items = await client.get(f"/tpos/api/v1/tposs/{tpos.id}/inventory-items") + assert items.status_code == 200 + assert items.json()[0] == { + "id": "item1", + "title": "Coffee", + "description": "Hot", + "price": 250, + "tax": 10, + "image": "https://example.com/coffee.png", + "categories": ["coffee"], + "quantity_in_stock": 3, + "disabled": False, + } + + async def bad_lnaddress(_lnaddress): + return object() + + monkeypatch.setattr(views_api, "lnurl_handle", bad_lnaddress) + lnaddress = await client.get( + "/tpos/api/v1/tposs/lnaddresscheck?lnaddress=alice@example.com" + ) + assert lnaddress.status_code == 400 + assert "unexpected response type" in lnaddress.json()["detail"] + + +@pytest.mark.asyncio +async def test_cash_validate_and_print_invoice_endpoints( + client: AsyncClient, monkeypatch +): + await _drain_internal_invoice_queue() + user, wallet = await _user_with_tabs("cashuser") + settings.super_user = user.id + headers = {"X-API-KEY": wallet.adminkey} + 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() + + payment = await create_payment_request( + wallet.id, + CreateInvoice( + unit="sat", + out=False, + amount=10, + memo="Cash sale", + internal=True, + extra={ + "tag": "tpos", + "tpos_id": tpos["id"], + "amount": 10, + "fiat_method": "cash", + "details": { + "currency": "EUR", + "exchangeRate": 1, + "taxValue": 0, + "taxIncluded": True, + "items": [], + }, + }, + ), + ) + await update_payment_checking_id( + payment.checking_id, f"internal_cash_{payment.payment_hash}" + ) + await create_tpos_payment( + TposPayment( + id=uuid4().hex, + tpos_id=tpos["id"], + payment_hash=payment.payment_hash, + amount=10, + payment_method="cash", + ) + ) + invoice = {"payment_hash": payment.payment_hash} + + sent_messages = [] + + async def fake_websocket_updater(channel, message): + sent_messages.append((channel, message)) + + monkeypatch.setattr(views_payments, "websocket_updater", fake_websocket_updater) + printed = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}/print", + json={"receipt_type": "receipt"}, + ) + assert printed.status_code == 200 + assert printed.json() == {"success": True} + order_printed = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}/print", + json={"receipt_type": "order_receipt"}, + ) + assert order_printed.status_code == 200 + assert order_printed.json() == {"success": True} + assert sent_messages + assert { + json.loads(message)["receipt_type"] for _channel, message in sent_messages + } == { + "receipt", + "order_receipt", + } + + poll = await client.get( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}?extra=true" + ) + assert poll.status_code == 200 + assert poll.json()["extra"]["fiat_method"] == "cash" + + queued_checking_ids = [] + + async def fake_internal_invoice_queue_put(checking_id): + queued_checking_ids.append(checking_id) + + monkeypatch.setattr( + views_payments, "internal_invoice_queue_put", fake_internal_invoice_queue_put + ) + validated = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}/cash/validate" + ) + assert validated.status_code == 200 + assert validated.json() == {"success": True} + + settled_payment = await get_standalone_payment(payment.payment_hash, incoming=True) + assert settled_payment is not None + assert settled_payment.success is True + + retried = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{invoice['payment_hash']}/cash/validate" + ) + assert retried.status_code == 200 + expected_checking_id = f"internal_cash_{payment.payment_hash}" + assert queued_checking_ids == [expected_checking_id, expected_checking_id] + + await on_invoice_paid(settled_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} + + latest_response = await client.get(f"/tpos/api/v1/tposs/{tpos['id']}/invoices") + assert latest_response.status_code == 200 + latest = latest_response.json() + assert latest[0]["pending"] is False + assert latest[0]["payment_method"] == "cash" + + +@pytest.mark.asyncio +async def test_atm_and_lnurl_withdraw_routes(client: AsyncClient, monkeypatch): + user, wallet = await _user_with_tabs("atmuser") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(withdraw_limit=100), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + + charge_response = await client.post( + f"/tpos/api/v1/atm/{tpos['id']}/create?usr={user.id}" + ) + assert charge_response.status_code == 200 + charge = charge_response.json() + + await update_wallet_balance(wallet, 50_000) + withdraw = await client.get(f"/tpos/api/v1/atm/withdraw/{charge['id']}/25") + assert withdraw.status_code == 200 + assert withdraw.json()["amount"] == 25 + + params = await client.get( + f"/tpos/api/v1/lnurl/{charge['id']}/25", + headers={"host": "localhost"}, + ) + assert params.status_code == 200 + assert params.json()["k1"] == charge["id"] + + async def fake_pay_invoice(**kwargs): + return None + + async def fake_websocket_updater(channel, message): + return None + + async def fake_pay_tribute(withdraw_amount, wallet_id, percent=0.5): + return None + + monkeypatch.setattr(views_lnurl, "pay_invoice", fake_pay_invoice) + monkeypatch.setattr(views_lnurl, "websocket_updater", fake_websocket_updater) + monkeypatch.setattr(views_lnurl, "pay_tribute", fake_pay_tribute) + callback = await client.get(f"/tpos/api/v1/lnurl/cb?k1={charge['id']}&pr=lnbc1test") + assert callback.status_code == 200 + assert callback.json()["status"] == "OK" + + claimed_again = await client.get( + f"/tpos/api/v1/lnurl/cb?k1={charge['id']}&pr=lnbc1test" + ) + assert claimed_again.status_code == 200 + assert "already been claimed" in claimed_again.json()["reason"] + + +@pytest.mark.asyncio +async def test_atm_pay_endpoint(client: AsyncClient, monkeypatch): + user, wallet = await _user_with_tabs("atmpayuser") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(withdraw_limit=100), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + + charge_response = await client.post( + f"/tpos/api/v1/atm/{tpos['id']}/create?usr={user.id}" + ) + assert charge_response.status_code == 200 + charge = charge_response.json() + + async def fake_lnurl_handle(pay_link, user_agent=None): + return views_atm.LnurlPayResponse( + callback="https://example.com/cb", + minSendable=1000, + maxSendable=1000, + metadata='[["text/plain","test"]]', + ) + + class FakePayResponse: + pr = "lnbc1test" + + async def fake_execute_pay_request(response, msat, user_agent=None): + assert msat == 25_000 + return FakePayResponse() + + async def fake_execute_withdraw(response, pr, user_agent=None): + assert pr == "lnbc1test" + return None + + monkeypatch.setattr(views_atm, "lnurl_handle", fake_lnurl_handle) + monkeypatch.setattr(views_atm, "execute_pay_request", fake_execute_pay_request) + monkeypatch.setattr(views_atm, "execute_withdraw", fake_execute_withdraw) + paid = await client.post( + f"/tpos/api/v1/atm/withdraw/{charge['id']}/25/pay", + json={"pay_link": "lnurl1test"}, + headers={"host": "localhost"}, + ) + assert paid.status_code == 200 + assert paid.json() == { + "success": True, + "message": "Withdraw processed successfully.", + } + + +@pytest.mark.asyncio +async def test_pay_invoice_lnurl_withdraw_endpoint(client: AsyncClient, monkeypatch): + _user, wallet = await _user_with_tabs("lnurlpayuser") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", json=_tpos_payload(), headers=headers + ) + assert create.status_code == 201 + tpos = create.json() + + class FakeResponse: + is_error = False + + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, **kwargs): + if "callback" in str(url): + return FakeResponse({"status": "OK"}) + return FakeResponse( + { + "tag": "withdrawRequest", + "callback": "https://example.com/callback", + "k1": "abc", + } + ) + + monkeypatch.setattr( + views_payments.httpx, "AsyncClient", lambda *args, **kwargs: FakeClient() + ) + paid = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay", + json={"lnurl": "example.com/withdraw"}, + ) + assert paid.status_code == 200 + assert paid.json()["success"] is True 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 189897e..c8334d4 100644 --- a/views_api.py +++ b/views_api.py @@ -1,274 +1,52 @@ import json -from datetime import datetime, timezone from http import HTTPStatus -from time import time -from typing import Any, Literal -from uuid import uuid4 -import httpx -from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, HTTPException, Query from lnbits.core.crud import ( - get_account, - get_standalone_payment, get_user, - get_wallet, ) -from lnbits.core.crud.payments import update_payment_checking_id -from lnbits.core.crud.users import ( - get_user_access_control_lists, - update_account, - update_user_access_control_list, -) -from lnbits.core.models import CreateInvoice, Payment, WalletTypeInfo -from lnbits.core.models.misc import SimpleItem -from lnbits.core.models.users import ( - AccessControlList, - AccessTokenPayload, - EndpointAccess, - UserLabel, -) -from lnbits.core.services import create_payment_request, websocket_updater +from lnbits.core.models import WalletTypeInfo from lnbits.decorators import ( require_admin_key, require_invoice_key, ) -from lnbits.helpers import create_access_token, get_api_routes -from lnbits.tasks import internal_invoice_queue_put from lnurl import LnurlPayResponse -from lnurl import decode as decode_lnurl from lnurl import handle as lnurl_handle from .crud import ( create_tpos, - create_tpos_payment, delete_tpos, - get_latest_tpos_payments, get_tpos, - get_tpos_payment_by_hash, get_tposs, update_tpos, ) from .helpers import ( - first_image, inventory_tags_to_list, inventory_tags_to_string, ) from .models import ( CreateTposData, - CreateTposInvoice, CreateUpdateItemData, - InventorySale, - PayLnurlWData, - PrintReceiptRequest, - ReceiptData, - ReceiptDetailsData, - ReceiptExtraData, - ReceiptItemData, - ReceiptPrint, - TapToPay, Tpos, - TposInvoiceResponse, - TposPayment, ) -from .services import ( - fetch_onchain_address, - fetch_watchonly_config, - fetch_watchonly_wallet, - fetch_watchonly_wallets, - fetch_wrapper_assetlinks, +from .services_inventory import ( get_default_inventory, - get_inventory_items_for_tpos, inventory_available_for_user, - watchonly_available_for_user, ) +from .views_inventory import tpos_inventory_router +from .views_onchain import _validate_watchonly_settings, tpos_onchain_router +from .views_payments import tpos_payments_router +from .views_tabs import ( + tpos_tabs_router, +) +from .views_wrapper import tpos_wrapper_router tpos_api_router = APIRouter() - - -def _two_year_token_expiry_minutes() -> int: - now = datetime.now(timezone.utc) - try: - expires_at = now.replace(year=now.year + 2) - except ValueError: - # Handle February 29 by falling back to February 28 two years later. - expires_at = now.replace(year=now.year + 2, month=2, day=28) - return max(1, int((expires_at - now).total_seconds() // 60)) - - -@tpos_api_router.get("/api/v1/well-known/assetlinks.json") -async def api_tpos_assetlinks() -> JSONResponse: - try: - assetlinks = await fetch_wrapper_assetlinks() - except RuntimeError as exc: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail=str(exc), - ) from exc - return JSONResponse(content=assetlinks, media_type="application/json") - - -def _build_receipt_data( - tpos: Tpos, payment: Payment, tpos_payment: TposPayment | None = None -) -> ReceiptData: - extra = payment.extra or {} - details = extra.get("details") or {} - items = details.get("items") or [] - - receipt_items = [ - ReceiptItemData( - title=str(item.get("title") or ""), - note=(str(item.get("note")) if item.get("note") is not None else None), - quantity=int(item.get("quantity") or 0), - price=float(item.get("price") or 0.0), - ) - for item in items - ] - - return ReceiptData( - paid=payment.success or bool(tpos_payment and tpos_payment.paid), - extra=ReceiptExtraData( - amount=int(extra.get("amount") or 0), - paid_in_fiat=bool(extra.get("paid_in_fiat")), - fiat_method=extra.get("fiat_method"), - fiat_payment_request=extra.get("fiat_payment_request"), - details=ReceiptDetailsData( - currency=str(details.get("currency") or "sats"), - exchange_rate=float(details.get("exchangeRate") or 1.0), - tax_value=float(details.get("taxValue") or 0.0), - tax_included=bool(details.get("taxIncluded")), - items=receipt_items, - ), - ), - created_at=payment.created_at, - business_name=tpos.business_name, - business_address=tpos.business_address, - business_vat_id=tpos.business_vat_id, - only_show_sats_on_bitcoin=tpos.only_show_sats_on_bitcoin, - ) - - -async def _get_watchonly_status(wallet) -> dict[str, Any]: - if not await watchonly_available_for_user(wallet.user): - return { - "available": False, - "message": "Watchonly extension must be enabled for this user.", - "network": None, - "wallets": [], - } - - try: - config = await fetch_watchonly_config(wallet.inkey) - network_value = config.get("network") - if not isinstance(network_value, str) or not network_value: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly extension returned an invalid network configuration.", - ) - network = network_value - wallets = await fetch_watchonly_wallets(wallet.inkey, network) - except HTTPException: - raise - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Watchonly extension is not reachable: {exc!s}", - ) from exc - - return { - "available": True, - "message": None, - "network": network, - "wallets": wallets, - "mempool_endpoint": config.get("mempool_endpoint"), - } - - -async def _validate_watchonly_settings( - *, - wallet, - onchain_enabled: bool, - onchain_wallet_id: str | None, -) -> dict[str, Any] | None: - if not onchain_enabled: - return None - if not onchain_wallet_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly wallet is required when onchain payments are enabled.", - ) - - status = await _get_watchonly_status(wallet) - if not status["available"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=status["message"] or "Watchonly extension is not available.", - ) - - try: - watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Cannot access watchonly wallet: {exc!s}", - ) from exc - - if watch_wallet.get("network") != status["network"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly wallet network does not match the user watchonly config.", - ) - - return { - "watch_wallet": watch_wallet, - "network": status["network"], - "mempool_endpoint": status["mempool_endpoint"], - } - - -def _payment_method_from_payment(payment: Payment) -> str: - if payment.extra.get("payment_method"): - return str(payment.extra["payment_method"]) - if payment.extra.get("fiat_method") == "cash": - return "cash" - if payment.extra.get("fiat_payment_request", "").startswith("pi_"): - return "fiat" - return "lightning" - - -def _serialize_tpos_invoice_response( - payment: Payment, tpos_payment: TposPayment -) -> TposInvoiceResponse: - payment_method = _payment_method_from_payment(payment) - payment_request = "lightning:" + payment.bolt11.upper() - if payment_method == "cash": - payment_request = "cash" - elif payment.extra.get("fiat_payment_request") and not payment.extra.get( - "fiat_payment_request", "" - ).startswith("pi_"): - payment_request = payment.extra["fiat_payment_request"] - elif payment_method == "fiat": - payment_request = "tap_to_pay" - elif payment_method == "onchain" and tpos_payment.onchain_address: - payment_request = tpos_payment.onchain_address - - options = [payment_method] - if tpos_payment.onchain_address: - options = ["btc", "btc_onchain"] - - return TposInvoiceResponse( - payment_hash=payment.payment_hash, - bolt11=payment.bolt11, - payment_request=payment_request, - tpos_payment_id=tpos_payment.id, - payment_options=options, - onchain_address=tpos_payment.onchain_address, - onchain_amount_sat=( - tpos_payment.amount if tpos_payment.onchain_address else None - ), - payment_method=payment_method, - extra=payment.extra or {}, - ) +tpos_api_router.include_router(tpos_inventory_router) +tpos_api_router.include_router(tpos_onchain_router) +tpos_api_router.include_router(tpos_tabs_router) +tpos_api_router.include_router(tpos_wrapper_router) +tpos_api_router.include_router(tpos_payments_router) @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) @@ -283,31 +61,6 @@ async def api_tposs( return await get_tposs(wallet_ids) -@tpos_api_router.get("/api/v1/inventory/status", status_code=HTTPStatus.OK) -async def api_inventory_status( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> dict: - user = await get_user(wallet.wallet.user) - if not inventory_available_for_user(user): - return {"enabled": False, "inventory_id": None, "tags": [], "omit_tags": []} - inventory = await get_default_inventory(wallet.wallet.user) - tags = inventory_tags_to_list(inventory.get("tags")) if inventory else [] - omit_tags = inventory_tags_to_list(inventory.get("omit_tags")) if inventory else [] - return { - "enabled": True, - "inventory_id": inventory.get("id") if inventory else None, - "tags": tags, - "omit_tags": omit_tags, - } - - -@tpos_api_router.get("/api/v1/onchain/status", status_code=HTTPStatus.OK) -async def api_onchain_status( - key_info: WalletTypeInfo = Depends(require_admin_key), -) -> dict[str, Any]: - return await _get_watchonly_status(key_info.wallet) - - @tpos_api_router.post("/api/v1/tposs", status_code=HTTPStatus.CREATED) async def api_tpos_create( data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -318,6 +71,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 @@ -352,6 +107,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 ) @@ -363,6 +119,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 @@ -418,496 +177,6 @@ async def api_tpos_delete( return "", HTTPStatus.NO_CONTENT -@tpos_api_router.post("/api/v1/tposs/{tpos_id}/wrapper-token") -async def api_tpos_create_wrapper_token( - tpos_id: str, - request: Request, - wallet: WalletTypeInfo = Depends(require_admin_key), -): - tpos = await get_tpos(tpos_id) - - if not tpos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." - ) - - if tpos.wallet != wallet.wallet.id: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") - - account = await get_account(wallet.wallet.user) - if not account or not account.username: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="A username is required to create a wrapper ACL token.", - ) - - user_acls = await get_user_access_control_lists(account.id) - acl_name = "TPoS Wrapper Fiat" - acl = next( - ( - existing_acl - for existing_acl in user_acls.access_control_list - if existing_acl.name == acl_name - ), - None, - ) - - api_routes = get_api_routes(request.app.router.routes) - fiat_endpoints = [] - for path, name in api_routes.items(): - is_fiat_endpoint = path.startswith("/api/v1/fiat") - fiat_endpoints.append( - EndpointAccess( - path=path, - name=name, - read=is_fiat_endpoint, - write=is_fiat_endpoint, - ) - ) - fiat_endpoints.sort(key=lambda e: e.name.lower()) - - if acl: - acl.endpoints = fiat_endpoints - else: - acl = AccessControlList( - id=uuid4().hex, - name=acl_name, - endpoints=fiat_endpoints, - token_id_list=[], - ) - user_acls.access_control_list.append(acl) - user_acls.access_control_list.sort( - key=lambda existing_acl: existing_acl.name.lower() - ) - - token_expire_minutes = _two_year_token_expiry_minutes() - api_token_id = uuid4().hex - payload = AccessTokenPayload( - sub=account.username, api_token_id=api_token_id, auth_time=int(time()) - ) - api_token = create_access_token( - data=payload.dict(), token_expire_minutes=token_expire_minutes - ) - - acl.token_id_list.append( - SimpleItem(id=api_token_id, name=f"TPoS Wrapper {tpos_id}") - ) - await update_user_access_control_list(user_acls) - - return {"auth": api_token, "expiration_time_minutes": token_expire_minutes} - - -@tpos_api_router.post( - "/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED -) -async def api_tpos_create_invoice( - tpos_id: str, data: CreateTposInvoice, request: Request -) -> dict[str, Any]: - tpos = await get_tpos(tpos_id) - - if not tpos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." - ) - - inventory_payload: InventorySale | None = data.inventory - if inventory_payload: - if not tpos.use_inventory or not tpos.inventory_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Inventory is not enabled for this TPoS.", - ) - inventory_payload.tags = inventory_tags_to_list(inventory_payload.tags) - if tpos.inventory_id and inventory_payload.inventory_id != tpos.inventory_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Mismatched inventory selection.", - ) - allowed_tags = set(inventory_tags_to_list(tpos.inventory_tags)) - if allowed_tags and any( - tag not in allowed_tags for tag in inventory_payload.tags - ): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Provided tags are not allowed for this TPoS.", - ) - - if not data.details: - tax_value = 0.0 - if tpos.tax_default: - tax_value = ( - (data.amount / data.exchange_rate) * (tpos.tax_default * 0.01) - if data.exchange_rate - else 0.0 - ) - data.details = { - "currency": tpos.currency, - "exchangeRate": data.exchange_rate, - "items": None, - "taxIncluded": True, - "taxValue": tax_value, - } - - cash_method = data.pay_in_fiat and data.fiat_method == "cash" - onchain_method = data.payment_method == "btc_onchain" - if cash_method and not tpos.allow_cash_settlement: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Cash settlement is not enabled for this TPoS.", - ) - if onchain_method and not tpos.onchain_enabled: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Onchain payments are not enabled for this TPoS.", - ) - currency = tpos.currency if data.pay_in_fiat else "sat" - amount = data.amount + (data.tip_amount or 0.0) - if data.pay_in_fiat: - amount = (data.amount_fiat or 0.0) + (data.tip_amount_fiat or 0.0) - - try: - extra = { - "tag": "tpos", - "tip_amount": data.tip_amount, - "tpos_id": tpos_id, - "amount": data.amount, - "exchangeRate": data.exchange_rate if data.exchange_rate else None, - "details": data.details if data.details else None, - "notes": data.notes if data.notes else None, - "lnaddress": data.user_lnaddress if data.user_lnaddress else None, - "internal_memo": data.internal_memo if data.internal_memo else None, - "paid_in_fiat": data.pay_in_fiat, - "base_url": str(request.base_url), - } - 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: - extra["fiat_method"] = data.fiat_method if data.fiat_method else "checkout" - if data.fiat_method == "terminal" and tpos.stripe_reader_id: - extra["terminal"] = {"reader_id": tpos.stripe_reader_id} - if onchain_method: - extra["payment_method"] = "onchain" - invoice_data = CreateInvoice( - unit=currency, - out=False, - amount=amount, - memo=f"{data.memo} to {tpos.name}" if data.memo else f"{tpos.name}", - extra=extra, - fiat_provider=( - tpos.fiat_provider if data.pay_in_fiat and not cash_method else None - ), - 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) - if cash_method: - new_checking_id = f"internal_cash_{payment.payment_hash}" - await update_payment_checking_id(payment.checking_id, new_checking_id) - payment.checking_id = new_checking_id - elif onchain_method: - new_checking_id = f"internal_onchain_{payment.payment_hash}" - await update_payment_checking_id(payment.checking_id, new_checking_id) - payment.checking_id = new_checking_id - - onchain_address = None - mempool_endpoint = None - if onchain_method: - wallet_record = await get_wallet(tpos.wallet) - if not wallet_record: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Wallet not found for this TPoS.", - ) - validation = await _validate_watchonly_settings( - wallet=wallet_record, - onchain_enabled=tpos.onchain_enabled, - onchain_wallet_id=tpos.onchain_wallet_id, - ) - assert validation - address_data = await fetch_onchain_address( - wallet_record.inkey, tpos.onchain_wallet_id or "" - ) - onchain_address = address_data.get("address") - mempool_endpoint = validation.get("mempool_endpoint") - - tpos_payment = await create_tpos_payment( - TposPayment( - id=uuid4().hex, - tpos_id=tpos_id, - payment_hash=payment.payment_hash, - amount=int(data.amount + (data.tip_amount or 0)), - onchain_address=onchain_address, - onchain_wallet_id=tpos.onchain_wallet_id, - onchain_zero_conf=tpos.onchain_zero_conf, - mempool_endpoint=mempool_endpoint, - ) - ) - response_payload = _serialize_tpos_invoice_response(payment, tpos_payment) - - if tpos.enable_remote: - payload = { - "type": "invoice_created", - "tpos_id": tpos_id, - "payment_hash": payment.payment_hash, - "payment_request": response_payload.payment_request, - "paid_in_fiat": data.pay_in_fiat, - "amount_fiat": data.amount_fiat, - "tip_amount": data.tip_amount, - "exchange_rate": data.exchange_rate if data.exchange_rate else None, - "tpos_payment_id": response_payload.tpos_payment_id, - "payment_options": response_payload.payment_options, - "onchain_address": response_payload.onchain_address, - "onchain_amount_sat": response_payload.onchain_amount_sat, - "payment_method": response_payload.payment_method, - } - await websocket_updater(tpos_id, json.dumps(payload)) - - if (invoice_data.extra or {}).get("fiat_method") == "terminal": - pi_id = payment.extra.get("fiat_checking_id") - client_secret = payment.extra.get("fiat_payment_request") - if pi_id and client_secret: - amount_minor = round(amount * 100) - tap_to_pay_payload = TapToPay( - payment_intent_id=pi_id, - client_secret=client_secret, - currency=invoice_data.unit.lower(), - amount=amount_minor, - tpos_id=tpos_id, - payment_hash=payment.payment_hash, - ) - await websocket_updater(tpos_id, json.dumps(tap_to_pay_payload.dict())) - return response_payload.dict() - - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) - ) from exc - - -@tpos_api_router.get("/api/v1/tposs/{tpos_id}/invoices") -async def api_tpos_get_latest_invoices(tpos_id: str): - tpos_payments = await get_latest_tpos_payments(tpos_id) - result = [] - for tpos_payment in tpos_payments: - payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True) - if not payment: - continue - details = payment.extra.get("details", {}) - currency = details.get("currency", None) - exchange_rate = details.get("exchangeRate") or payment.extra.get("exchangeRate") - result.append( - { - "checking_id": payment.checking_id, - "amount": payment.amount, - "time": payment.time, - "pending": not tpos_payment.paid, - "currency": currency, - "exchange_rate": exchange_rate, - "payment_method": tpos_payment.payment_method, - } - ) - return result - - -@tpos_api_router.post( - "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK -) -async def api_tpos_pay_invoice( - lnurl_data: PayLnurlWData, payment_request: str, tpos_id: str -): - tpos = await get_tpos(tpos_id) - - if not tpos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." - ) - - lnurl = ( - lnurl_data.lnurl.replace("lnurlw://", "") - .replace("lightning://", "") - .replace("LIGHTNING://", "") - .replace("lightning:", "") - .replace("LIGHTNING:", "") - ) - - if lnurl.lower().startswith("lnurl"): - lnurl = decode_lnurl(lnurl) - else: - lnurl = "https://" + lnurl - - async with httpx.AsyncClient() as client: - try: - headers = {"user-agent": "lnbits/tpos"} - r = await client.get(lnurl, follow_redirects=True, headers=headers) - if r.is_error: - lnurl_response = {"success": False, "detail": "Error loading"} - else: - resp = r.json() - if resp.get("status") == "ERROR": - lnurl_response = { - "success": False, - "detail": resp.get("reason", ""), - } - return lnurl_response - - if resp.get("tag") != "withdrawRequest": - lnurl_response = {"success": False, "detail": "Wrong tag type"} - else: - r2 = await client.get( - resp.get("callback", ""), - follow_redirects=True, - headers=headers, - params={ - "k1": resp.get("k1", ""), - "pr": payment_request, - }, - ) - resp2 = r2.json() - if r2.is_error: - lnurl_response = { - "success": False, - "detail": "Error loading callback", - } - elif resp2.get("status") == "ERROR": - lnurl_response = {"success": False, "detail": resp2["reason"]} - else: - lnurl_response = {"success": True, "detail": resp2} - except (httpx.ConnectError, httpx.RequestError): - lnurl_response = {"success": False, "detail": "Unexpected error occurred"} - - return lnurl_response - - -@tpos_api_router.get( - "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK -) -async def api_tpos_check_invoice( - tpos_id: str, payment_hash: str, extra: bool = Query(False) -): - tpos = await get_tpos(tpos_id) - if not tpos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." - ) - payment = await get_standalone_payment(payment_hash, incoming=True) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - if payment.extra.get("tag") != "tpos": - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." - ) - tpos_payment = await get_tpos_payment_by_hash(payment_hash) - - if extra: - return _build_receipt_data(tpos, payment, tpos_payment).to_api_dict() - return {"paid": payment.success or bool(tpos_payment and tpos_payment.paid)} - - -@tpos_api_router.post( - "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/print", - status_code=HTTPStatus.OK, -) -async def api_tpos_print_invoice( - data: PrintReceiptRequest, tpos_id: str, payment_hash: str -): - tpos = await get_tpos(tpos_id) - if not tpos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." - ) - - payment = await get_standalone_payment(payment_hash, incoming=True) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - if payment.extra.get("tag") != "tpos" or payment.extra.get("tpos_id") != tpos_id: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." - ) - - receipt_type: Literal["receipt", "order_receipt"] = ( - "order_receipt" if data.receipt_type == "order_receipt" else "receipt" - ) - tpos_payment = await get_tpos_payment_by_hash(payment_hash) - receipt = _build_receipt_data(tpos, payment, tpos_payment) - payload = ReceiptPrint( - tpos_id=tpos_id, - payment_hash=payment_hash, - receipt_type=receipt_type, - print_text=receipt.render_text(receipt_type), - receipt=receipt.to_api_dict(), - ) - await websocket_updater(tpos_id, json.dumps(payload.dict())) - return {"success": True} - - -@tpos_api_router.post( - "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/cash/validate", - status_code=HTTPStatus.OK, -) -async def api_tpos_validate_cash_invoice(tpos_id: str, payment_hash: str): - tpos = await get_tpos(tpos_id) - if not tpos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." - ) - if not tpos.allow_cash_settlement: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Cash settlement is not enabled for this TPoS.", - ) - payment = await get_standalone_payment(payment_hash, incoming=True) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - if payment.extra.get("tag") != "tpos" or payment.extra.get("tpos_id") != tpos_id: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." - ) - if payment.extra.get("fiat_method") != "cash": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Payment is not cash." - ) - if not payment.is_internal: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Payment is not an internal cash invoice.", - ) - if payment.success: - return {"success": True} - await internal_invoice_queue_put(payment.checking_id) - return {"success": True} - - @tpos_api_router.put("/api/v1/tposs/{tpos_id}/items", status_code=HTTPStatus.CREATED) async def api_tpos_create_items( data: CreateUpdateItemData, @@ -944,60 +213,3 @@ async def api_tpos_check_lnaddress(lnaddress: str): ) return True - - -@tpos_api_router.get( - "/api/v1/tposs/{tpos_id}/inventory-items", status_code=HTTPStatus.OK -) -async def api_tpos_inventory_items(tpos_id: str): - tpos = await get_tpos(tpos_id) - if not tpos or not tpos.use_inventory: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Inventory not enabled for this TPoS.", - ) - - wallet = await get_wallet(tpos.wallet) - if not wallet: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Wallet not found for this TPoS.", - ) - - inventory_id = tpos.inventory_id - inventory_data: dict[str, Any] | None = None - if not inventory_id: - inventory_data = await get_default_inventory(wallet.user) - inventory_id = inventory_data.get("id") if inventory_data else None - else: - inventory_data = await get_default_inventory(wallet.user) - if not inventory_id: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="No inventory found for this TPoS.", - ) - - items = await get_inventory_items_for_tpos( - wallet.user, - inventory_id, - tpos.inventory_tags, - tpos.inventory_omit_tags, - ) - return [ - { - "id": item.get("id"), - "title": item.get("name"), - "description": item.get("description"), - "price": item.get("price"), - "tax": item.get("tax_rate"), - "image": first_image(item.get("images")), - "categories": inventory_tags_to_list(item.get("tags")), - "quantity_in_stock": item.get("quantity_in_stock"), - "disabled": (not item.get("is_active")) - or ( - item.get("quantity_in_stock") is not None - and item.get("quantity_in_stock") <= 0 - ), - } - for item in items - ] diff --git a/views_inventory.py b/views_inventory.py new file mode 100644 index 0000000..7866f9a --- /dev/null +++ b/views_inventory.py @@ -0,0 +1,89 @@ +from http import HTTPStatus +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key + +from .crud import get_tpos +from .helpers import first_image, inventory_tags_to_list +from .services_inventory import ( + get_default_inventory, + get_inventory_items_for_tpos, + inventory_available_for_user, +) + +tpos_inventory_router = APIRouter() + + +@tpos_inventory_router.get("/api/v1/inventory/status", status_code=HTTPStatus.OK) +async def api_inventory_status( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict: + user = await get_user(wallet.wallet.user) + if not inventory_available_for_user(user): + return {"enabled": False, "inventory_id": None, "tags": [], "omit_tags": []} + inventory = await get_default_inventory(wallet.wallet.user) + tags = inventory_tags_to_list(inventory.get("tags")) if inventory else [] + omit_tags = inventory_tags_to_list(inventory.get("omit_tags")) if inventory else [] + return { + "enabled": True, + "inventory_id": inventory.get("id") if inventory else None, + "tags": tags, + "omit_tags": omit_tags, + } + + +@tpos_inventory_router.get( + "/api/v1/tposs/{tpos_id}/inventory-items", status_code=HTTPStatus.OK +) +async def api_tpos_inventory_items(tpos_id: str) -> list[dict[str, Any]]: + tpos = await get_tpos(tpos_id) + if not tpos or not tpos.use_inventory: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Inventory not enabled for this TPoS.", + ) + + wallet = await get_wallet(tpos.wallet) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Wallet not found for this TPoS.", + ) + + inventory_id = tpos.inventory_id + if not inventory_id: + inventory = await get_default_inventory(wallet.user) + inventory_id = inventory.get("id") if inventory else None + if not inventory_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No inventory found for this TPoS.", + ) + + items = await get_inventory_items_for_tpos( + wallet.user, + inventory_id, + tpos.inventory_tags, + tpos.inventory_omit_tags, + ) + return [ + { + "id": item.get("id"), + "title": item.get("name"), + "description": item.get("description"), + "price": item.get("price"), + "tax": item.get("tax_rate"), + "image": first_image(item.get("images")), + "categories": inventory_tags_to_list(item.get("tags")), + "quantity_in_stock": item.get("quantity_in_stock"), + "disabled": (not item.get("is_active")) + or ( + item.get("quantity_in_stock") is not None + and item.get("quantity_in_stock") <= 0 + ), + } + for item in items + ] diff --git a/views_onchain.py b/views_onchain.py new file mode 100644 index 0000000..2fcbf9c --- /dev/null +++ b/views_onchain.py @@ -0,0 +1,100 @@ +from http import HTTPStatus +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key + +from .services_onchain import ( + fetch_watchonly_config, + fetch_watchonly_wallet, + fetch_watchonly_wallets, + watchonly_available_for_user, +) + +tpos_onchain_router = APIRouter() + + +async def _get_watchonly_status(wallet) -> dict[str, Any]: + if not await watchonly_available_for_user(wallet.user): + return { + "available": False, + "message": "Watchonly extension must be enabled for this user.", + "network": None, + "wallets": [], + } + + try: + config = await fetch_watchonly_config(wallet.inkey) + network_value = config.get("network") + if not isinstance(network_value, str) or not network_value: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly extension returned an invalid network configuration.", + ) + network = network_value + wallets = await fetch_watchonly_wallets(wallet.inkey, network) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Watchonly extension is not reachable: {exc!s}", + ) from exc + + return { + "available": True, + "message": None, + "network": network, + "wallets": wallets, + "mempool_endpoint": config.get("mempool_endpoint"), + } + + +async def _validate_watchonly_settings( + *, + wallet, + onchain_enabled: bool, + onchain_wallet_id: str | None, +) -> dict[str, Any] | None: + if not onchain_enabled: + return None + if not onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly wallet is required when onchain payments are enabled.", + ) + + status = await _get_watchonly_status(wallet) + if not status["available"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=status["message"] or "Watchonly extension is not available.", + ) + + try: + watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cannot access watchonly wallet: {exc!s}", + ) from exc + + if watch_wallet.get("network") != status["network"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly wallet network does not match the user watchonly config.", + ) + + return { + "watch_wallet": watch_wallet, + "network": status["network"], + "mempool_endpoint": status["mempool_endpoint"], + } + + +@tpos_onchain_router.get("/api/v1/onchain/status", status_code=HTTPStatus.OK) +async def api_onchain_status( + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> dict[str, Any]: + return await _get_watchonly_status(key_info.wallet) diff --git a/views_payments.py b/views_payments.py new file mode 100644 index 0000000..fb2abe4 --- /dev/null +++ b/views_payments.py @@ -0,0 +1,567 @@ +import json +from http import HTTPStatus +from typing import Any, Literal +from uuid import uuid4 + +import httpx +from fastapi import APIRouter, HTTPException, Query, Request +from lnbits.core.crud import get_account, get_standalone_payment, get_wallet +from lnbits.core.crud.payments import update_payment, update_payment_checking_id +from lnbits.core.crud.users import update_account +from lnbits.core.models import CreateInvoice, Payment, PaymentState +from lnbits.core.models.users import UserLabel +from lnbits.core.services import create_payment_request, websocket_updater +from lnbits.tasks import internal_invoice_queue_put +from lnurl import decode as decode_lnurl + +from .crud import ( + create_tpos_payment, + get_latest_tpos_payments, + get_tpos, + get_tpos_payment_by_hash, +) +from .helpers import inventory_tags_to_list +from .models import ( + CreateTposInvoice, + InventorySale, + PayLnurlWData, + PrintReceiptRequest, + ReceiptData, + ReceiptDetailsData, + ReceiptExtraData, + ReceiptItemData, + ReceiptPrint, + TapToPay, + Tpos, + TposInvoiceResponse, + TposPayment, +) +from .services import ensure_tpos_tabs_access +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 + +tpos_payments_router = APIRouter() + + +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED +) +async def api_tpos_create_invoice( + tpos_id: str, data: CreateTposInvoice, request: Request +) -> dict[str, Any]: + tpos = await get_tpos(tpos_id) + + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + inventory_payload: InventorySale | None = data.inventory + if inventory_payload: + if not tpos.use_inventory or not tpos.inventory_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Inventory is not enabled for this TPoS.", + ) + inventory_payload.tags = inventory_tags_to_list(inventory_payload.tags) + if tpos.inventory_id and inventory_payload.inventory_id != tpos.inventory_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Mismatched inventory selection.", + ) + allowed_tags = set(inventory_tags_to_list(tpos.inventory_tags)) + if allowed_tags and any( + tag not in allowed_tags for tag in inventory_payload.tags + ): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Provided tags are not allowed for this TPoS.", + ) + + if not data.details: + tax_value = 0.0 + if tpos.tax_default: + tax_value = ( + (data.amount / data.exchange_rate) * (tpos.tax_default * 0.01) + if data.exchange_rate + else 0.0 + ) + data.details = { + "currency": tpos.currency, + "exchangeRate": data.exchange_rate, + "items": None, + "taxIncluded": True, + "taxValue": tax_value, + } + + cash_method = data.pay_in_fiat and data.fiat_method == "cash" + onchain_method = data.payment_method == "btc_onchain" + if cash_method and not tpos.allow_cash_settlement: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Cash settlement is not enabled for this TPoS.", + ) + if onchain_method and not tpos.onchain_enabled: + raise HTTPException( + 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 get_tab_for_tpos(user_id, tpos, tab_settlement.tab_id) + 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: + amount = (data.amount_fiat or 0.0) + (data.tip_amount_fiat or 0.0) + + try: + extra = { + "tag": "tpos", + "tip_amount": data.tip_amount, + "tpos_id": tpos_id, + "amount": data.amount, + "exchangeRate": data.exchange_rate if data.exchange_rate else None, + "details": data.details if data.details else None, + "notes": data.notes if data.notes else None, + "lnaddress": data.user_lnaddress if data.user_lnaddress else None, + "internal_memo": data.internal_memo if data.internal_memo else None, + "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: + 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: + extra["fiat_method"] = data.fiat_method if data.fiat_method else "checkout" + if data.fiat_method == "terminal" and tpos.stripe_reader_id: + extra["terminal"] = {"reader_id": tpos.stripe_reader_id} + if onchain_method: + extra["payment_method"] = "onchain" + invoice_data = CreateInvoice( + unit=currency, + out=False, + amount=amount, + memo=f"{data.memo} to {tpos.name}" if data.memo else f"{tpos.name}", + extra=extra, + fiat_provider=( + tpos.fiat_provider if data.pay_in_fiat and not cash_method else None + ), + 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) + if cash_method: + new_checking_id = f"internal_cash_{payment.payment_hash}" + await update_payment_checking_id(payment.checking_id, new_checking_id) + payment.checking_id = new_checking_id + elif onchain_method: + new_checking_id = f"internal_onchain_{payment.payment_hash}" + await update_payment_checking_id(payment.checking_id, new_checking_id) + payment.checking_id = new_checking_id + + onchain_address = None + mempool_endpoint = None + if onchain_method: + wallet_record = await get_wallet(tpos.wallet) + if not wallet_record: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Wallet not found for this TPoS.", + ) + validation = await _validate_watchonly_settings( + wallet=wallet_record, + onchain_enabled=tpos.onchain_enabled, + onchain_wallet_id=tpos.onchain_wallet_id, + ) + assert validation + address_data = await fetch_onchain_address( + wallet_record.inkey, tpos.onchain_wallet_id or "" + ) + onchain_address = address_data.get("address") + mempool_endpoint = validation.get("mempool_endpoint") + + tpos_payment = await create_tpos_payment( + TposPayment( + id=uuid4().hex, + tpos_id=tpos_id, + payment_hash=payment.payment_hash, + amount=int(data.amount + (data.tip_amount or 0)), + onchain_address=onchain_address, + onchain_wallet_id=tpos.onchain_wallet_id, + onchain_zero_conf=tpos.onchain_zero_conf, + mempool_endpoint=mempool_endpoint, + ) + ) + response_payload = _serialize_tpos_invoice_response(payment, tpos_payment) + + if tpos.enable_remote: + payload = { + "type": "invoice_created", + "tpos_id": tpos_id, + "payment_hash": payment.payment_hash, + "payment_request": response_payload.payment_request, + "paid_in_fiat": data.pay_in_fiat, + "amount_fiat": data.amount_fiat, + "tip_amount": data.tip_amount, + "tip_amount_fiat": data.tip_amount_fiat, + "exchange_rate": data.exchange_rate if data.exchange_rate else None, + "tpos_payment_id": response_payload.tpos_payment_id, + "payment_options": response_payload.payment_options, + "onchain_address": response_payload.onchain_address, + "onchain_amount_sat": response_payload.onchain_amount_sat, + "payment_method": response_payload.payment_method, + } + await websocket_updater(tpos_id, json.dumps(payload)) + + if (invoice_data.extra or {}).get("fiat_method") == "terminal": + pi_id = payment.extra.get("fiat_checking_id") + client_secret = payment.extra.get("fiat_payment_request") + if pi_id and client_secret: + amount_minor = round(amount * 100) + tap_to_pay_payload = TapToPay( + payment_intent_id=pi_id, + client_secret=client_secret, + currency=invoice_data.unit.lower(), + amount=amount_minor, + tpos_id=tpos_id, + payment_hash=payment.payment_hash, + ) + await websocket_updater(tpos_id, json.dumps(tap_to_pay_payload.dict())) + return response_payload.dict() + + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) + ) from exc + + +@tpos_payments_router.get("/api/v1/tposs/{tpos_id}/invoices") +async def api_tpos_get_latest_invoices(tpos_id: str): + tpos_payments = await get_latest_tpos_payments(tpos_id) + result = [] + for tpos_payment in tpos_payments: + payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True) + if not payment: + continue + details = payment.extra.get("details", {}) + currency = details.get("currency", None) + exchange_rate = details.get("exchangeRate") or payment.extra.get("exchangeRate") + result.append( + { + "checking_id": payment.checking_id, + "amount": payment.amount, + "time": payment.time, + "pending": not tpos_payment.paid, + "currency": currency, + "exchange_rate": exchange_rate, + "payment_method": tpos_payment.payment_method, + } + ) + return result + + +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK +) +async def api_tpos_pay_invoice( + lnurl_data: PayLnurlWData, payment_request: str, tpos_id: str +): + tpos = await get_tpos(tpos_id) + + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + lnurl = ( + lnurl_data.lnurl.replace("lnurlw://", "") + .replace("lightning://", "") + .replace("LIGHTNING://", "") + .replace("lightning:", "") + .replace("LIGHTNING:", "") + ) + + if lnurl.lower().startswith("lnurl"): + lnurl = decode_lnurl(lnurl) + else: + lnurl = "https://" + lnurl + + async with httpx.AsyncClient() as client: + try: + headers = {"user-agent": "lnbits/tpos"} + r = await client.get(lnurl, follow_redirects=True, headers=headers) + if r.is_error: + lnurl_response = {"success": False, "detail": "Error loading"} + else: + resp = r.json() + if resp.get("status") == "ERROR": + lnurl_response = { + "success": False, + "detail": resp.get("reason", ""), + } + return lnurl_response + + if resp.get("tag") != "withdrawRequest": + lnurl_response = {"success": False, "detail": "Wrong tag type"} + else: + r2 = await client.get( + resp.get("callback", ""), + follow_redirects=True, + headers=headers, + params={ + "k1": resp.get("k1", ""), + "pr": payment_request, + }, + ) + resp2 = r2.json() + if r2.is_error: + lnurl_response = { + "success": False, + "detail": "Error loading callback", + } + elif resp2.get("status") == "ERROR": + lnurl_response = {"success": False, "detail": resp2["reason"]} + else: + lnurl_response = {"success": True, "detail": resp2} + except (httpx.ConnectError, httpx.RequestError): + lnurl_response = {"success": False, "detail": "Unexpected error occurred"} + + return lnurl_response + + +@tpos_payments_router.get( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK +) +async def api_tpos_check_invoice( + tpos_id: str, payment_hash: str, extra: bool = Query(False) +): + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + payment = await get_standalone_payment(payment_hash, incoming=True) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + if payment.extra.get("tag") != "tpos": + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." + ) + tpos_payment = await get_tpos_payment_by_hash(payment_hash) + + if extra: + return _build_receipt_data(tpos, payment, tpos_payment).to_api_dict() + return {"paid": payment.success or bool(tpos_payment and tpos_payment.paid)} + + +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/print", + status_code=HTTPStatus.OK, +) +async def api_tpos_print_invoice( + data: PrintReceiptRequest, tpos_id: str, payment_hash: str +): + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + payment = await get_standalone_payment(payment_hash, incoming=True) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + if payment.extra.get("tag") != "tpos" or payment.extra.get("tpos_id") != tpos_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." + ) + + receipt_type: Literal["receipt", "order_receipt"] = ( + "order_receipt" if data.receipt_type == "order_receipt" else "receipt" + ) + tpos_payment = await get_tpos_payment_by_hash(payment_hash) + receipt = _build_receipt_data(tpos, payment, tpos_payment) + payload = ReceiptPrint( + tpos_id=tpos_id, + payment_hash=payment_hash, + receipt_type=receipt_type, + print_text=receipt.render_text(receipt_type), + receipt=receipt.to_api_dict(), + ) + await websocket_updater(tpos_id, json.dumps(payload.dict())) + return {"success": True} + + +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/cash/validate", + status_code=HTTPStatus.OK, +) +async def api_tpos_validate_cash_invoice(tpos_id: str, payment_hash: str): + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + if not tpos.allow_cash_settlement: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Cash settlement is not enabled for this TPoS.", + ) + payment = await get_standalone_payment(payment_hash, incoming=True) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + if payment.extra.get("tag") != "tpos" or payment.extra.get("tpos_id") != tpos_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." + ) + if payment.extra.get("fiat_method") != "cash": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Payment is not cash." + ) + if not payment.is_internal: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Payment is not an internal cash invoice.", + ) + if not payment.success: + payment.status = PaymentState.SUCCESS + await update_payment(payment) + await internal_invoice_queue_put(payment.checking_id) + return {"success": True} + + +def _payment_method_from_payment(payment: Payment) -> str: + if payment.extra.get("payment_method"): + return str(payment.extra["payment_method"]) + if payment.extra.get("fiat_method") == "cash": + return "cash" + if payment.extra.get("fiat_payment_request", "").startswith("pi_"): + return "fiat" + return "lightning" + + +def _serialize_tpos_invoice_response( + payment: Payment, tpos_payment: TposPayment +) -> TposInvoiceResponse: + payment_method = _payment_method_from_payment(payment) + payment_request = "lightning:" + payment.bolt11.upper() + if payment_method == "cash": + payment_request = "cash" + elif payment.extra.get("fiat_payment_request") and not payment.extra.get( + "fiat_payment_request", "" + ).startswith("pi_"): + payment_request = payment.extra["fiat_payment_request"] + elif payment_method == "fiat": + payment_request = "tap_to_pay" + elif payment_method == "onchain" and tpos_payment.onchain_address: + payment_request = tpos_payment.onchain_address + + options = [payment_method] + if tpos_payment.onchain_address: + options = ["btc", "btc_onchain"] + + return TposInvoiceResponse( + payment_hash=payment.payment_hash, + bolt11=payment.bolt11, + payment_request=payment_request, + tpos_payment_id=tpos_payment.id, + payment_options=options, + onchain_address=tpos_payment.onchain_address, + onchain_amount_sat=( + tpos_payment.amount if tpos_payment.onchain_address else None + ), + payment_method=payment_method, + extra=payment.extra or {}, + ) + + +def _build_receipt_data( + tpos: Tpos, payment: Payment, tpos_payment: TposPayment | None = None +) -> ReceiptData: + extra = payment.extra or {} + details = extra.get("details") or {} + items = details.get("items") or [] + + receipt_items = [ + ReceiptItemData( + title=str(item.get("title") or ""), + note=(str(item.get("note")) if item.get("note") is not None else None), + quantity=int(item.get("quantity") or 0), + price=float(item.get("price") or 0.0), + ) + for item in items + ] + + return ReceiptData( + paid=payment.success or bool(tpos_payment and tpos_payment.paid), + extra=ReceiptExtraData( + amount=int(extra.get("amount") or 0), + paid_in_fiat=bool(extra.get("paid_in_fiat")), + fiat_method=extra.get("fiat_method"), + fiat_payment_request=extra.get("fiat_payment_request"), + details=ReceiptDetailsData( + currency=str(details.get("currency") or "sats"), + exchange_rate=float(details.get("exchangeRate") or 1.0), + tax_value=float(details.get("taxValue") or 0.0), + tax_included=bool(details.get("taxIncluded")), + items=receipt_items, + ), + ), + created_at=payment.created_at, + business_name=tpos.business_name, + business_address=tpos.business_address, + business_vat_id=tpos.business_vat_id, + only_show_sats_on_bitcoin=tpos.only_show_sats_on_bitcoin, + ) diff --git a/views_tabs.py b/views_tabs.py new file mode 100644 index 0000000..afe61f5 --- /dev/null +++ b/views_tabs.py @@ -0,0 +1,121 @@ +import json +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +from .crud import get_tpos +from .models import CreateTposTabCharge, CreateTposTabData, Tpos, TposTab, TposTabList +from .services import ensure_tpos_tabs_access +from .services_tabs import ( + create_tab_charge_for_tpos, + create_tab_for_tpos, + fetch_tabs_for_tpos, + get_tab_for_tpos, +) + +tpos_tabs_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() + + +@tpos_tabs_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_tabs_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_tabs_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) + + await get_tab_for_tpos(user_id, tpos, tab_id) + + 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 get_tab_for_tpos(user_id, tpos, tab_id) + return {"tab_id": tab_id, "entry": entry, "tab": TposTab(**updated_tab).dict()} diff --git a/views_wrapper.py b/views_wrapper.py new file mode 100644 index 0000000..f345fb4 --- /dev/null +++ b/views_wrapper.py @@ -0,0 +1,126 @@ +from datetime import datetime, timezone +from http import HTTPStatus +from time import time +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse +from lnbits.core.crud import get_account +from lnbits.core.crud.users import ( + get_user_access_control_lists, + update_user_access_control_list, +) +from lnbits.core.models import WalletTypeInfo +from lnbits.core.models.misc import SimpleItem +from lnbits.core.models.users import ( + AccessControlList, + AccessTokenPayload, + EndpointAccess, +) +from lnbits.decorators import require_admin_key +from lnbits.helpers import create_access_token, get_api_routes + +from .crud import get_tpos +from .services_wrapper import fetch_wrapper_assetlinks + +tpos_wrapper_router = APIRouter() + + +def _two_year_token_expiry_minutes() -> int: + now = datetime.now(timezone.utc) + try: + expires_at = now.replace(year=now.year + 2) + except ValueError: + expires_at = now.replace(year=now.year + 2, month=2, day=28) + return max(1, int((expires_at - now).total_seconds() // 60)) + + +@tpos_wrapper_router.get("/api/v1/well-known/assetlinks.json") +async def api_tpos_assetlinks() -> JSONResponse: + try: + assetlinks = await fetch_wrapper_assetlinks() + except RuntimeError as exc: + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + return JSONResponse(content=assetlinks, media_type="application/json") + + +@tpos_wrapper_router.post("/api/v1/tposs/{tpos_id}/wrapper-token") +async def api_tpos_create_wrapper_token( + tpos_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + tpos = await get_tpos(tpos_id) + + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + if tpos.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") + + account = await get_account(wallet.wallet.user) + if not account or not account.username: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A username is required to create a wrapper ACL token.", + ) + + user_acls = await get_user_access_control_lists(account.id) + acl_name = "TPoS Wrapper Fiat" + acl = next( + ( + existing_acl + for existing_acl in user_acls.access_control_list + if existing_acl.name == acl_name + ), + None, + ) + + api_routes = get_api_routes(request.app.router.routes) + fiat_endpoints = [] + for path, name in api_routes.items(): + is_fiat_endpoint = path.startswith("/api/v1/fiat") + fiat_endpoints.append( + EndpointAccess( + path=path, + name=name, + read=is_fiat_endpoint, + write=is_fiat_endpoint, + ) + ) + fiat_endpoints.sort(key=lambda e: e.name.lower()) + + if acl: + acl.endpoints = fiat_endpoints + else: + acl = AccessControlList( + id=uuid4().hex, + name=acl_name, + endpoints=fiat_endpoints, + token_id_list=[], + ) + user_acls.access_control_list.append(acl) + user_acls.access_control_list.sort( + key=lambda existing_acl: existing_acl.name.lower() + ) + + token_expire_minutes = _two_year_token_expiry_minutes() + api_token_id = uuid4().hex + payload = AccessTokenPayload( + sub=account.username, api_token_id=api_token_id, auth_time=int(time()) + ) + api_token = create_access_token( + data=payload.dict(), token_expire_minutes=token_expire_minutes + ) + + acl.token_id_list.append( + SimpleItem(id=api_token_id, name=f"TPoS Wrapper {tpos_id}") + ) + await update_user_access_control_list(user_acls) + + return {"auth": api_token, "expiration_time_minutes": token_expire_minutes}