diff --git a/crud.py b/crud.py index 70ff1a3..2d6af4f 100644 --- a/crud.py +++ b/crud.py @@ -6,9 +6,20 @@ 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() - tpos = Tpos(id=tpos_id, **data.dict()) + data_dict = data.dict() + data_dict["inventory_tags"] = _serialize_inventory_tags(data.inventory_tags) + data_dict["inventory_omit_tags"] = _serialize_inventory_tags( + data.inventory_omit_tags + ) + tpos = Tpos(id=tpos_id, **data_dict) await db.insert("tpos.pos", tpos) return tpos @@ -46,6 +57,8 @@ async def get_clean_tpos(tpos_id: str) -> TposClean | None: async def update_tpos(tpos: Tpos) -> Tpos: + tpos.inventory_tags = _serialize_inventory_tags(tpos.inventory_tags) + tpos.inventory_omit_tags = _serialize_inventory_tags(tpos.inventory_omit_tags) await db.update("tpos.pos", tpos) return tpos diff --git a/migrations.py b/migrations.py index 168ecbc..d040b46 100644 --- a/migrations.py +++ b/migrations.py @@ -224,3 +224,35 @@ async def m015_addfiat(db: Database): ALTER TABLE tpos.pos ADD stripe_card_payments BOOLEAN DEFAULT false; """ ) + + +async def m016_add_inventory_settings(db: Database): + """ + Add inventory integration columns + """ + await db.execute( + """ + ALTER TABLE tpos.pos ADD use_inventory BOOLEAN DEFAULT false; + """ + ) + await db.execute( + """ + ALTER TABLE tpos.pos ADD inventory_id TEXT NULL; + """ + ) + await db.execute( + """ + ALTER TABLE tpos.pos ADD inventory_tags TEXT NULL; + """ + ) + + +async def m017_add_inventory_omit_tags(db: Database): + """ + Add inventory omit tags column + """ + await db.execute( + """ + ALTER TABLE tpos.pos ADD inventory_omit_tags TEXT NULL; + """ + ) diff --git a/models.py b/models.py index 4d988a8..3b3c155 100644 --- a/models.py +++ b/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from time import time from fastapi import Query @@ -17,6 +19,7 @@ class CreateTposInvoice(BaseModel): memo: str | None = Query(None) exchange_rate: float | None = Query(None, ge=0.0) details: dict | None = Query(None) + inventory: InventorySale | None = Query(None) tip_amount: int | None = Query(None, ge=1) user_lnaddress: str | None = Query(None) internal_memo: str | None = Query(None, max_length=512) @@ -26,12 +29,27 @@ class CreateTposInvoice(BaseModel): tip_amount_fiat: float | None = Query(None, ge=0.0) +class InventorySaleItem(BaseModel): + id: str + quantity: int = Field(1, ge=1) + + +class InventorySale(BaseModel): + inventory_id: str + tags: list[str] = Field(default_factory=list) + items: list[InventorySaleItem] = Field(default_factory=list) + + class CreateTposData(BaseModel): wallet: str | None name: str | None currency: str | None + use_inventory: bool = Field(False) + inventory_id: str | None = None + inventory_tags: list[str] | None = None + inventory_omit_tags: list[str] | None = None tax_inclusive: bool = Field(True) - tax_default: float = Field(0.0) + tax_default: float | None = Field(0.0) tip_options: str = Field("[]") tip_wallet: str = Field("") withdraw_time: int = Field(0) @@ -48,6 +66,10 @@ class CreateTposData(BaseModel): fiat_provider: str | None = Field(None) stripe_card_payments: bool = False + @validator("tax_default", pre=True, always=True) + def default_tax_when_none(cls, v): + return 0.0 if v is None else v + class TposClean(BaseModel): id: str @@ -64,6 +86,10 @@ class TposClean(BaseModel): lnaddress: bool | None = None lnaddress_cut: int = 0 items: str | None = None + use_inventory: bool = False + inventory_id: str | None = None + inventory_tags: str | None = None + inventory_omit_tags: str | None = None tip_options: str | None = None enable_receipt_print: bool business_name: str | None = None @@ -129,3 +155,6 @@ class TapToPay(BaseModel): tpos_id: str | None = None payment_hash: str | None = None paid: bool = False + + +CreateTposInvoice.update_forward_refs(InventorySale=InventorySale) diff --git a/static/js/index.js b/static/js/index.js index 2b37f27..824e327 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -11,6 +11,16 @@ const mapTpos = obj => { obj.tpos ].join('') obj.items = obj.items ? JSON.parse(obj.items) : [] + obj.use_inventory = Boolean(obj.use_inventory) + obj.inventory_id = obj.inventory_id || null + const tagString = + obj.inventory_tags === 'null' ? '' : obj.inventory_tags || '' + obj.inventory_tags = tagString ? tagString.split(',').filter(Boolean) : [] + const omitTagString = + obj.inventory_omit_tags === 'null' ? '' : obj.inventory_omit_tags || '' + obj.inventory_omit_tags = omitTagString + ? omitTagString.split(',').filter(Boolean) + : [] obj.itemsMap = new Map() obj.items.forEach((item, idx) => { let id = `${obj.id}:${idx + 1}` @@ -28,6 +38,12 @@ window.app = Vue.createApp({ currencyOptions: [], hasFiatProvider: false, fiatProviders: null, + inventoryStatus: { + enabled: false, + inventory_id: null, + tags: [], + omit_tags: [] + }, tpossTable: { columns: [ {name: 'name', align: 'left', label: 'Name', field: 'name'}, @@ -80,6 +96,9 @@ window.app = Vue.createApp({ formDialog: { show: false, data: { + use_inventory: false, + inventory_id: null, + inventory_tags: [], tip_options: [], withdraw_between: 10, withdraw_time_option: '', @@ -182,12 +201,38 @@ window.app = Vue.createApp({ !data.wallet || (this.formDialog.advanced.otc && !data.withdraw_limit) ) + }, + inventoryModeOptions() { + return [ + { + label: 'Use inventory extension', + value: true, + disable: !this.inventoryStatus.enabled + }, + {label: 'Use TPoS items', value: false} + ] + }, + inventoryTagOptions() { + return (this.inventoryStatus.tags || []).map(tag => ({ + label: tag, + value: tag + })) + }, + inventoryOmitTagOptions() { + return (this.inventoryStatus.omit_tags || []).map(tag => ({ + label: tag, + value: tag + })) } }, methods: { closeFormDialog() { this.formDialog.show = false this.formDialog.data = { + use_inventory: false, + inventory_id: this.inventoryStatus.inventory_id, + inventory_tags: [...(this.inventoryStatus.tags || [])], + inventory_omit_tags: [...(this.inventoryStatus.omit_tags || [])], tip_options: [], withdraw_between: 10, withdraw_time_option: '', @@ -213,6 +258,25 @@ window.app = Vue.createApp({ }) }) }, + async loadInventoryStatus() { + if (!this.g.user.wallets.length) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/tpos/api/v1/inventory/status', + this.g.user.wallets[0].adminkey + ) + this.inventoryStatus = data + // Default remains "Use TPoS items"; keep inventory info available without auto-enabling. + if (!this.formDialog.data.inventory_id) { + this.formDialog.data.inventory_id = data.inventory_id + this.formDialog.data.inventory_tags = [...data.tags] + this.formDialog.data.inventory_omit_tags = [...data.omit_tags] + } + } catch (error) { + console.error(error) + } + }, sendTposData() { const data = { ...this.formDialog.data, @@ -225,7 +289,16 @@ window.app = Vue.createApp({ tip_wallet: (this.formDialog.advanced.tips && this.formDialog.data.tip_wallet) || '', - items: JSON.stringify(this.formDialog.data.items) + items: JSON.stringify(this.formDialog.data.items || []) + } + data.inventory_tags = data.inventory_tags || [] + if (!this.inventoryStatus.enabled) { + data.use_inventory = false + } else if (!data.inventory_id) { + data.inventory_id = this.inventoryStatus.inventory_id + } + if (data.use_inventory && !data.inventory_id) { + data.use_inventory = false } // delete withdraw_between if value is empty string, defaults to 10 minutes if (this.formDialog.data.withdraw_between == '') { @@ -285,6 +358,64 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) }) }, + saveInventorySettings(tpos) { + const wallet = _.findWhere(this.g.user.wallets, {id: tpos.wallet}) + if (!wallet) return + const resolvedInventoryId = + this.inventoryStatus.inventory_id || tpos.inventory_id + const payload = { + use_inventory: this.inventoryStatus.enabled && tpos.use_inventory, + inventory_id: + this.inventoryStatus.enabled && tpos.use_inventory + ? resolvedInventoryId + : null, + inventory_tags: tpos.inventory_tags || [], + inventory_omit_tags: tpos.inventory_omit_tags || [] + } + if (payload.use_inventory && !payload.inventory_id) { + Quasar.Notify.create({ + type: 'warning', + message: 'No inventory found for this user.' + }) + return + } + LNbits.api + .request( + 'PUT', + `/tpos/api/v1/tposs/${tpos.id}`, + wallet.adminkey, + payload + ) + .then(response => { + this.tposs = _.reject(this.tposs, obj => obj.id == tpos.id) + this.tposs.push(mapTpos(response.data)) + }) + .catch(LNbits.utils.notifyApiError) + }, + onInventoryModeChange(tpos, value) { + tpos.use_inventory = value + if (value && this.inventoryStatus.enabled) { + tpos.inventory_id = this.inventoryStatus.inventory_id + if (!tpos.inventory_tags.length && this.inventoryStatus.tags.length) { + tpos.inventory_tags = [...this.inventoryStatus.tags] + } + if ( + !tpos.inventory_omit_tags.length && + this.inventoryStatus.omit_tags + ) { + tpos.inventory_omit_tags = [...this.inventoryStatus.omit_tags] + } + } + this.saveInventorySettings(tpos) + }, + onInventoryTagsChange(tpos, tags) { + tpos.inventory_tags = tags || [] + this.saveInventorySettings(tpos) + }, + onInventoryOmitTagsChange(tpos, tags) { + tpos.inventory_omit_tags = tags || [] + this.saveInventorySettings(tpos) + }, deleteTpos(tposId) { const tpos = _.findWhere(this.tposs, {id: tposId}) @@ -506,6 +637,7 @@ window.app = Vue.createApp({ created() { if (this.g.user.wallets.length) { this.getTposs() + this.loadInventoryStatus() } LNbits.api .request('GET', '/api/v1/currencies') diff --git a/static/js/tpos.js b/static/js/tpos.js index 2afbf01..8df374c 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -56,6 +56,12 @@ window.app = Vue.createApp({ moreBtn: false, total: 0.0, cartTax: 0.0, + items: [], + categories: [], + usingInventory: false, + inventoryTags: [], + inventoryId: null, + inventoryLoading: false, itemsTable: { filter: '', columns: [ @@ -122,7 +128,20 @@ window.app = Vue.createApp({ currency_choice: false, _currencyResolver: null, _withdrawing: false, - headerElement: null + headerElement: null, + categoryColors: {}, + categoryColorIndex: 0, + pastelColors: [ + 'blue-5', + 'green-5', + 'cyan-5', + 'purple-5', + 'deep-purple-5', + 'indigo-5', + 'pink-5', + 'amber-5', + 'orange-5' + ] } }, watch: { @@ -234,6 +253,20 @@ window.app = Vue.createApp({ } }, methods: { + setColor(category) { + if (!category || category.toLowerCase() === 'all') { + return 'primary' + } + const key = category.toLowerCase() + if (this.categoryColors[key]) { + return this.categoryColors[key] + } + const color = + this.pastelColors[this.categoryColorIndex % this.pastelColors.length] + this.categoryColors[key] = color + this.categoryColorIndex += 1 + return color + }, addAmount() { this.addedAmount += this.amount this.total = +(this.total + this.amount).toFixed(2) @@ -245,6 +278,21 @@ window.app = Vue.createApp({ this.stack = [] }, addToCart(item, quantity = 1) { + if ( + this.usingInventory && + item.quantity_in_stock !== null && + item.quantity_in_stock !== undefined + ) { + const inCart = this.itemCartQty(item.id) + if (inCart >= item.quantity_in_stock) { + Quasar.Notify.create({ + type: 'warning', + message: 'Not enough stock available.' + }) + return + } + quantity = Math.min(quantity, item.quantity_in_stock - inCart) + } if (this.cart.has(item.id)) { this.cart.set(item.id, { ...this.cart.get(item.id), @@ -301,7 +349,47 @@ window.app = Vue.createApp({ this.cartTax = 0.0 this.total = 0.0 this.addedAmount = 0.0 - this.cartDrawer = false + if (this.$q.screen.lt.md) { + this.cartDrawer = false + } + }, + formatAndSetItems(items, keepIds = false) { + this.items = (items || []).map((item, idx) => { + const parsed = {...item} + parsed.tax = item.tax || 0 + parsed.formattedPrice = this.formatAmount(item.price, this.currency) + parsed.id = keepIds ? item.id : idx + parsed.categories = parsed.categories || [] + parsed.disabled = Boolean(parsed.disabled) + return parsed + }) + if (this.items.length > 0) { + this.showPoS = false + this.categoryColors = {} + this.categoryColorIndex = 0 + this.categories = this.extractCategories(this.items) + } + }, + async loadInventoryItems() { + if (!this.usingInventory) return + this.inventoryLoading = true + try { + const {data} = await LNbits.api.request( + 'GET', + `/tpos/api/v1/tposs/${this.tposId}/inventory-items` + ) + this.formatAndSetItems( + data.map(item => ({ + ...item, + tax: item.tax ?? this.taxDefault + })), + true + ) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.inventoryLoading = false + } }, holdCart() { if (!this.cart.size) { @@ -606,6 +694,7 @@ window.app = Vue.createApp({ currency: this.currency, exchangeRate: this.exchangeRate, items: [...this.cart.values()].map(item => ({ + id: item.id, price: item.price, formattedPrice: item.formattedPrice, quantity: item.quantity, @@ -619,6 +708,16 @@ window.app = Vue.createApp({ if (this.lnaddress) { params.user_lnaddress = this.lnaddressDialog.lnaddress } + if (this.usingInventory && this.cart.size) { + params.inventory = { + inventory_id: this.inventoryId, + tags: this.inventoryTags, + items: [...this.cart.values()].map(item => ({ + id: item.id, + quantity: item.quantity + })) + } + } return params }, async showInvoice() { @@ -1052,15 +1151,14 @@ window.app = Vue.createApp({ this.tip_options.push('Round') } - this.items = tpos.items - this.items.forEach((item, id) => { - item.formattedPrice = this.formatAmount(item.price, this.currency) - item.id = id - return item - }) - if (this.items.length > 0) { - this.showPoS = false - this.categories = this.extractCategories(this.items) + this.usingInventory = Boolean(tpos.use_inventory) + this.inventoryTags = tpos.inventory_tags || [] + this.inventoryId = tpos.inventory_id + + if (this.usingInventory && this.inventoryId) { + await this.loadInventoryItems() + } else { + this.formatAndSetItems(tpos.items) } this.exchangeRate = await this.getRates() this.heldCarts = JSON.parse( diff --git a/tasks.py b/tasks.py index 3c2eb01..3f81a29 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,7 @@ import asyncio +import httpx +from lnbits.core.crud import get_wallet from lnbits.core.models import Payment from lnbits.core.services import ( create_invoice, @@ -7,12 +9,46 @@ pay_invoice, 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 +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 + ids: list[str] = [] + quantities: list[int] = [] + 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: + return + # 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 wait_for_paid_invoices(): invoice_queue = asyncio.Queue() register_invoice_listener(invoice_queue, "ext_tpos") @@ -64,6 +100,10 @@ async def on_invoice_paid(payment: Payment) -> None: await websocket_updater(tpos_id, str(stripped_payment)) + inventory_payload = payment.extra.get("inventory") + if inventory_payload: + await _deduct_inventory_stock(payment.wallet_id, inventory_payload) + if not tip_amount: # no tip amount return diff --git a/templates/tpos/_cart.html b/templates/tpos/_cart.html index d9b058d..382ef3d 100644 --- a/templates/tpos/_cart.html +++ b/templates/tpos/_cart.html @@ -131,8 +131,10 @@
-
+
+ @click="isGridView = !isGridView" > - + + class="cursor-pointer" > - - {% include "tpos/_options_fab.html" %} -
-
- - + +
+ + +
{% include "tpos/_options_fab.html" %}
-
+ +
-
+
- + - -
- -
-
- -
-
- -
+ - - -
- -
-
- -
-
- -
- -
- -
- -
-
+ +
@@ -290,14 +212,17 @@
:src="item.image" fit="contain" > + + + TPoS
-
+
+
+ + + inventory extension must be enabled. + + +
+
+ + +
+
+
+ + Using items from the Inventory extension. Tags control which + products appear in PoS. + +
+
TPoS
unelevated @click="openItemDialog(props.row.id)" class="float-left q-my-sm" + :disable="props.row.use_inventory" >Add Item TPoS unelevated @click="deleteAllItems(props.row.id)" class="float-left q-my-sm q-ml-sm" + :disable="props.row.use_inventory" >Delete All TPoS outline color="primary" label="Import/Export" + :disable="props.row.use_inventory" > TPoS +
TPoS dense @click="deleteItem(props.row.id)" icon="delete" + :disable="props.row.use_inventory" > @@ -228,6 +289,7 @@
TPoS
dense @click="openItemDialog(props.row.id)" icon="edit" + :disable="props.row.use_inventory" >
diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html index 79dbee7..a367d7b 100644 --- a/templates/tpos/tpos.html +++ b/templates/tpos/tpos.html @@ -145,6 +145,18 @@
object-fit: scale-down; } + .item-grid-title { + display: block; + padding: 4px 6px 6px; + font-weight: 600; + font-size: 0.95rem; + color: #fff; + background: rgba(0, 0, 0, 0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .table { border-collapse: collapse; width: 100%; @@ -188,6 +200,13 @@
const tpos = JSON.parse({{ tpos | tojson | safe }}) tpos.items = tpos.items !== null ? JSON.parse(tpos.items) : [] tpos.tip_options = tpos.tip_options ? JSON.parse(tpos.tip_options) : [] + tpos.inventory_tags = tpos.inventory_tags + ? tpos.inventory_tags.split(',').filter(Boolean) + : [] + tpos.inventory_omit_tags = tpos.inventory_omit_tags + ? tpos.inventory_omit_tags.split(',').filter(Boolean) + : [] + tpos.use_inventory = Boolean(tpos.use_inventory) if (tpos.withdraw_maximum) { tpos.withdraw_premium = Number(tpos.withdraw_maximum / 100) } diff --git a/views_api.py b/views_api.py index cbd7d05..3bb1b5c 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,6 @@ import json from http import HTTPStatus +from typing import Any import httpx from fastapi import APIRouter, Depends, HTTPException, Query @@ -7,13 +8,16 @@ get_latest_payments_by_extension, get_standalone_payment, get_user, + get_wallet, ) -from lnbits.core.models import CreateInvoice, Payment, WalletTypeInfo +from lnbits.core.models import CreateInvoice, Payment, User, 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 @@ -29,6 +33,7 @@ CreateTposData, CreateTposInvoice, CreateUpdateItemData, + InventorySale, PayLnurlWData, TapToPay, Tpos, @@ -37,6 +42,127 @@ 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), @@ -49,11 +175,40 @@ 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.post("/api/v1/tposs", status_code=HTTPStatus.CREATED) async def api_tpos_create( - data: CreateTposData, key_type: WalletTypeInfo = Depends(require_admin_key) + data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key) ): - data.wallet = key_type.wallet.id + data.wallet = wallet.wallet.id + user = await get_user(wallet.wallet.user) + if data.use_inventory and not _inventory_available_for_user(user): + data.use_inventory = False + if data.use_inventory and not data.inventory_id: + inventory = await _get_default_inventory(wallet.wallet.user) + if not inventory: + data.use_inventory = False + else: + data.inventory_id = inventory.get("id") + data.inventory_tags = inventory.get("tags") + data.inventory_omit_tags = inventory.get("omit_tags") tpos = await create_tpos(data) return tpos @@ -71,7 +226,31 @@ async def api_tpos_update( ) if wallet.wallet.id != tpos.wallet: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") - for field, value in data.dict().items(): + user = await get_user(wallet.wallet.user) + update_payload = data.dict(exclude_unset=True) + if update_payload.get("use_inventory") and not update_payload.get("inventory_id"): + inventory = await _get_default_inventory(wallet.wallet.user) + if inventory: + update_payload["inventory_id"] = inventory.get("id") + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No inventory found for this user.", + ) + if update_payload.get("use_inventory") and not _inventory_available_for_user(user): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Inventory extension must be enabled to use it.", + ) + if "inventory_tags" in update_payload: + update_payload["inventory_tags"] = _inventory_tags_to_string( + _inventory_tags_to_list(update_payload["inventory_tags"]) + ) + if "inventory_omit_tags" in update_payload: + update_payload["inventory_omit_tags"] = _inventory_tags_to_string( + _inventory_tags_to_list(update_payload["inventory_omit_tags"]) + ) + for field, value in update_payload.items(): setattr(tpos, field, value) tpos = await update_tpos(tpos) return tpos @@ -106,6 +285,28 @@ async def api_tpos_create_invoice(tpos_id: str, data: CreateTposInvoice) -> Paym 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: @@ -138,6 +339,8 @@ async def api_tpos_create_invoice(tpos_id: str, data: CreateTposInvoice) -> Paym "lnaddress": data.user_lnaddress if data.user_lnaddress else None, "internal_memo": data.internal_memo if data.internal_memo else None, } + if inventory_payload: + extra["inventory"] = inventory_payload.dict() if data.pay_in_fiat and tpos.fiat_provider: extra["fiat_method"] = data.fiat_method if data.fiat_method else "checkout" invoice_data = CreateInvoice( @@ -332,3 +535,60 @@ 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 + ]