From 168b23c9f261b14a425c905dbaa060fd92537888 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 09:18:08 +0000 Subject: [PATCH 1/7] remove host:port --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 3f81a29..5e1936f 100644 --- a/tasks.py +++ b/tasks.py @@ -42,7 +42,7 @@ async def _deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> No ) async with httpx.AsyncClient() as client: await client.patch( - url=f"http://{settings.host}:{settings.port}/inventory/api/v1/items/{inventory_id}/quantities", + url=f"inventory/api/v1/items/{inventory_id}/quantities", headers={"Authorization": f"Bearer {access}"}, params={"source": "tpos", "ids": ids, "quantities": quantities}, ) From 88329d862e127c8bf4937c63e318c8d900b18584 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 09:18:42 +0000 Subject: [PATCH 2/7] fix parallel lists --- tasks.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tasks.py b/tasks.py index 5e1936f..0934e09 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,8 @@ import asyncio import httpx +from loguru import logger + from lnbits.core.crud import get_wallet from lnbits.core.models import Payment from lnbits.core.services import ( @@ -10,9 +12,7 @@ websocket_updater, ) from lnbits.helpers import create_access_token -from lnbits.settings import settings from lnbits.tasks import register_invoice_listener -from loguru import logger from .crud import get_tpos @@ -25,17 +25,19 @@ async def _deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> No items = inventory_payload.get("items") or [] if not inventory_id or not items: return - ids: list[str] = [] - quantities: list[int] = [] + 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 - ids.append(item_id) - quantities.append(int(qty)) - if not ids: + 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 From 96cc69be7d97e42c398228263ebff4f41a6e5912 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 09:19:41 +0000 Subject: [PATCH 3/7] private methods in bottom python best practices --- tasks.py | 68 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/tasks.py b/tasks.py index 0934e09..00ce9fb 100644 --- a/tasks.py +++ b/tasks.py @@ -17,40 +17,6 @@ from .crud import get_tpos -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_access_token( - {"sub": "", "usr": wallet.user}, token_expire_minutes=1 - ) - async with httpx.AsyncClient() as client: - await client.patch( - url=f"inventory/api/v1/items/{inventory_id}/quantities", - headers={"Authorization": f"Bearer {access}"}, - params={"source": "tpos", "ids": ids, "quantities": quantities}, - ) - return - - async def wait_for_paid_invoices(): invoice_queue = asyncio.Queue() register_invoice_listener(invoice_queue, "ext_tpos") @@ -127,3 +93,37 @@ async def on_invoice_paid(payment: Payment) -> None: extra={**payment.extra, "tipSplitted": True}, ) logger.debug(f"tpos: tip invoice paid: {paid_payment.checking_id}") + + +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_access_token( + {"sub": "", "usr": wallet.user}, token_expire_minutes=1 + ) + async with httpx.AsyncClient() as client: + await client.patch( + url=f"inventory/api/v1/items/{inventory_id}/quantities", + headers={"Authorization": f"Bearer {access}"}, + params={"source": "tpos", "ids": ids, "quantities": quantities}, + ) + return From dc352e6165ce89f5359f5a972d01c45dcb7f2b3e Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 10:13:27 +0000 Subject: [PATCH 4/7] extract functions to helpers and services --- crud.py | 7 +-- helpers.py | 59 +++++++++++++++++++++ services.py | 116 +++++++++++++++++++++++++++++++++++++++++ tasks.py | 38 +------------- views_api.py | 142 ++++++--------------------------------------------- 5 files changed, 192 insertions(+), 170 deletions(-) create mode 100644 helpers.py create mode 100644 services.py diff --git a/crud.py b/crud.py index 2d6af4f..a8cdc96 100644 --- a/crud.py +++ b/crud.py @@ -1,17 +1,12 @@ from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash +from .helpers import _serialize_inventory_tags from .models import CreateTposData, LnurlCharge, Tpos, TposClean db = Database("ext_tpos") -def _serialize_inventory_tags(tags: list[str] | str | None) -> str | None: - if isinstance(tags, list): - return ",".join([tag for tag in tags if tag]) - return tags - - async def create_tpos(data: CreateTposData) -> Tpos: tpos_id = urlsafe_short_hash() data_dict = data.dict() diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..16f0743 --- /dev/null +++ b/helpers.py @@ -0,0 +1,59 @@ +import json + +from loguru import logger + + +def _serialize_inventory_tags(tags: list[str] | str | None) -> str | None: + if isinstance(tags, list): + return ",".join([tag for tag in tags if tag]) + return tags + + +def _inventory_tags_to_list(raw_tags: str | list[str] | None) -> list[str]: + if raw_tags is None: + return [] + if isinstance(raw_tags, list): + return [tag.strip() for tag in raw_tags if tag and tag.strip()] + return [tag.strip() for tag in raw_tags.split(",") if tag and tag.strip()] + + +def _inventory_tags_to_string(raw_tags: str | list[str] | None) -> str | None: + if raw_tags is None: + return None + if isinstance(raw_tags, str): + return raw_tags + return ",".join([tag for tag in raw_tags if tag]) + + +def _first_image(images: str | list[str] | None) -> str | None: + if not images: + return None + if isinstance(images, list): + return _normalize_image(images[0]) if images else None + raw = str(images).strip() + if not raw: + return None + try: + parsed = json.loads(raw) + if isinstance(parsed, list) and parsed: + return _normalize_image(parsed[0]) + except Exception as exc: + logger.exception(f"Exception occurred while parsing image JSON: {exc}") + + if "|||" in raw: + return _normalize_image(raw.split("|||")[0]) + + if "," in raw: + return _normalize_image(raw.split(",")[0]) + return _normalize_image(raw) + + +def _normalize_image(val: str | None) -> str | None: + if not val: + return None + val = str(val).strip() + if not val: + return None + if val.startswith("http") or val.startswith("/api/") or val.startswith("data:"): + return val + return f"/api/v1/assets/{val}/binary" diff --git a/services.py b/services.py new file mode 100644 index 0000000..155b602 --- /dev/null +++ b/services.py @@ -0,0 +1,116 @@ +from typing import Any + +import httpx + +from lnbits.core.crud import get_wallet +from lnbits.core.models import User +from lnbits.helpers import create_access_token +from lnbits.settings import settings + +from .helpers import _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_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}, + ) + return + + +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}"}, + ) + 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}, + ) + payload = resp.json() + items = payload.get("data", []) if isinstance(payload, dict) else payload + + 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/tasks.py b/tasks.py index 00ce9fb..cbe5b5d 100644 --- a/tasks.py +++ b/tasks.py @@ -1,9 +1,7 @@ import asyncio -import httpx from loguru import logger -from lnbits.core.crud import get_wallet from lnbits.core.models import Payment from lnbits.core.services import ( create_invoice, @@ -11,10 +9,10 @@ pay_invoice, websocket_updater, ) -from lnbits.helpers import create_access_token from lnbits.tasks import register_invoice_listener from .crud import get_tpos +from .services import _deduct_inventory_stock async def wait_for_paid_invoices(): @@ -93,37 +91,3 @@ async def on_invoice_paid(payment: Payment) -> None: extra={**payment.extra, "tipSplitted": True}, ) logger.debug(f"tpos: tip invoice paid: {paid_payment.checking_id}") - - -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_access_token( - {"sub": "", "usr": wallet.user}, token_expire_minutes=1 - ) - async with httpx.AsyncClient() as client: - await client.patch( - url=f"inventory/api/v1/items/{inventory_id}/quantities", - headers={"Authorization": f"Bearer {access}"}, - params={"source": "tpos", "ids": ids, "quantities": quantities}, - ) - return diff --git a/views_api.py b/views_api.py index 0db1663..168d190 100644 --- a/views_api.py +++ b/views_api.py @@ -4,23 +4,22 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Query +from lnurl import LnurlPayResponse +from lnurl import decode as decode_lnurl +from lnurl import handle as lnurl_handle + from lnbits.core.crud import ( get_latest_payments_by_extension, get_standalone_payment, get_user, get_wallet, ) -from lnbits.core.models import CreateInvoice, Payment, User, WalletTypeInfo +from lnbits.core.models import CreateInvoice, Payment, WalletTypeInfo from lnbits.core.services import create_payment_request, websocket_updater from lnbits.decorators import ( require_admin_key, require_invoice_key, ) -from lnbits.helpers import create_access_token -from lnbits.settings import settings -from lnurl import LnurlPayResponse -from lnurl import decode as decode_lnurl -from lnurl import handle as lnurl_handle from .crud import ( create_tpos, @@ -29,6 +28,11 @@ get_tposs, update_tpos, ) +from .helpers import ( + _first_image, + _inventory_tags_to_list, + _inventory_tags_to_string, +) from .models import ( CreateTposData, CreateTposInvoice, @@ -38,131 +42,15 @@ TapToPay, Tpos, ) +from .services import ( + _get_default_inventory, + _get_inventory_items_for_tpos, + _inventory_available_for_user, +) tpos_api_router = APIRouter() -def _inventory_tags_to_list(raw_tags: str | list[str] | None) -> list[str]: - if raw_tags is None: - return [] - if isinstance(raw_tags, list): - return [tag.strip() for tag in raw_tags if tag and tag.strip()] - return [tag.strip() for tag in raw_tags.split(",") if tag and tag.strip()] - - -def _inventory_tags_to_string(raw_tags: str | list[str] | None) -> str | None: - if raw_tags is None: - return None - if isinstance(raw_tags, str): - return raw_tags - return ",".join([tag for tag in raw_tags if tag]) - - -def _first_image(images: str | list[str] | None) -> str | None: - if not images: - return None - if isinstance(images, list): - return _normalize_image(images[0]) if images else None - raw = str(images).strip() - if not raw: - return None - try: - parsed = json.loads(raw) - if isinstance(parsed, list) and parsed: - return _normalize_image(parsed[0]) - except Exception: - pass - if "|||" in raw: - return _normalize_image(raw.split("|||")[0]) - if "," in raw: - return _normalize_image(raw.split(",")[0]) - return _normalize_image(raw) - - -def _normalize_image(val: str | None) -> str | None: - if not val: - return None - val = str(val).strip() - if not val: - return None - if val.startswith("http") or val.startswith("/api/") or val.startswith("data:"): - return val - return f"/api/v1/assets/{val}/binary" - - -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}"}, - ) - 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}, - ) - payload = resp.json() - items = payload.get("data", []) if isinstance(payload, dict) else payload - - 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 [])) - - @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( all_wallets: bool = Query(False), From f2b9deb2dd7cef6c805e480ea201c84bb77e5e13 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 10:14:50 +0000 Subject: [PATCH 5/7] chore: linter --- services.py | 1 - tasks.py | 3 +-- views_api.py | 7 +++---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/services.py b/services.py index 155b602..95dc741 100644 --- a/services.py +++ b/services.py @@ -1,7 +1,6 @@ from typing import Any import httpx - from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.helpers import create_access_token diff --git a/tasks.py b/tasks.py index cbe5b5d..00688ef 100644 --- a/tasks.py +++ b/tasks.py @@ -1,7 +1,5 @@ import asyncio -from loguru import logger - from lnbits.core.models import Payment from lnbits.core.services import ( create_invoice, @@ -10,6 +8,7 @@ websocket_updater, ) from lnbits.tasks import register_invoice_listener +from loguru import logger from .crud import get_tpos from .services import _deduct_inventory_stock diff --git a/views_api.py b/views_api.py index 168d190..c089524 100644 --- a/views_api.py +++ b/views_api.py @@ -4,10 +4,6 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Query -from lnurl import LnurlPayResponse -from lnurl import decode as decode_lnurl -from lnurl import handle as lnurl_handle - from lnbits.core.crud import ( get_latest_payments_by_extension, get_standalone_payment, @@ -20,6 +16,9 @@ require_admin_key, require_invoice_key, ) +from lnurl import LnurlPayResponse +from lnurl import decode as decode_lnurl +from lnurl import handle as lnurl_handle from .crud import ( create_tpos, From 6fea733b284651071bb2e1c3a06931d50c514816 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 15:29:09 +0000 Subject: [PATCH 6/7] images are a csv of ids --- helpers.py | 7 +++++++ services.py | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/helpers.py b/helpers.py index 16f0743..468d553 100644 --- a/helpers.py +++ b/helpers.py @@ -3,6 +3,13 @@ from loguru import logger +def _from_csv(value: str | None, separator: str = ",") -> list[str]: + if not value: + return [] + parts = [part.strip() for part in value.split(separator)] + return [part for part in parts if part] + + def _serialize_inventory_tags(tags: list[str] | str | None) -> str | None: if isinstance(tags, list): return ",".join([tag for tag in tags if tag]) diff --git a/services.py b/services.py index 95dc741..8c18b4d 100644 --- a/services.py +++ b/services.py @@ -1,12 +1,13 @@ from typing import Any import httpx + from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.helpers import create_access_token from lnbits.settings import settings -from .helpers import _inventory_tags_to_list +from .helpers import _from_csv, _inventory_tags_to_list async def _deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: @@ -81,6 +82,11 @@ async def _get_inventory_items_for_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: From b9624afe87cb2f0df261ae6a47e42eb8095c01c0 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 18 Dec 2025 15:32:21 +0000 Subject: [PATCH 7/7] chore: linter --- services.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services.py b/services.py index 8c18b4d..21cbb20 100644 --- a/services.py +++ b/services.py @@ -1,7 +1,6 @@ from typing import Any import httpx - from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.helpers import create_access_token