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.
+
+
+
+
+
+
+
+
+
+
+
+ Receipt printing is an experimental feature. Not all devices work
+ correctly, or work at all.
+
+
+
+
+
+
+
+
+ Hit enter to add values
+
+ You can leave this blank. A default rounding option is available
+ (round amount to a value)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
-
-
-
-
-
-
-
-
-
-
- Receipt printing is an experimental feature. Not all devices work
- correctly, or work at all.
-
-
-
-
-
-
-
-
- Hit enter to add values
-
- You can leave this blank. A default rounding option is available
- (round amount to a value)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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}