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 @@