From 0eb141592ab4397f69016737b4993c17ffa8ba94 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Wed, 10 Dec 2025 23:26:02 +0000 Subject: [PATCH 01/24] init --- crud.py | 11 +- migrations.py | 21 ++++ models.py | 21 ++++ static/js/index.js | 117 ++++++++++++++++++++- static/js/tpos.js | 85 ++++++++++++++-- tasks.py | 68 +++++++++++++ templates/tpos/index.html | 87 ++++++++++++---- templates/tpos/tpos.html | 4 + views_api.py | 208 +++++++++++++++++++++++++++++++++++++- 9 files changed, 590 insertions(+), 32 deletions(-) diff --git a/crud.py b/crud.py index 70ff1a3..42cc73e 100644 --- a/crud.py +++ b/crud.py @@ -6,9 +6,17 @@ 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) + tpos = Tpos(id=tpos_id, **data_dict) await db.insert("tpos.pos", tpos) return tpos @@ -46,6 +54,7 @@ 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) await db.update("tpos.pos", tpos) return tpos diff --git a/migrations.py b/migrations.py index 168ecbc..b426e3f 100644 --- a/migrations.py +++ b/migrations.py @@ -224,3 +224,24 @@ 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; + """ + ) diff --git a/models.py b/models.py index 4d988a8..a0e2d8f 100644 --- a/models.py +++ b/models.py @@ -17,6 +17,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,10 +27,24 @@ 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 tax_inclusive: bool = Field(True) tax_default: float = Field(0.0) tip_options: str = Field("[]") @@ -64,6 +79,9 @@ 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 tip_options: str | None = None enable_receipt_print: bool business_name: str | None = None @@ -129,3 +147,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..6bd1424 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -11,6 +11,11 @@ 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) : [] obj.itemsMap = new Map() obj.items.forEach((item, idx) => { let id = `${obj.id}:${idx + 1}` @@ -28,6 +33,11 @@ window.app = Vue.createApp({ currencyOptions: [], hasFiatProvider: false, fiatProviders: null, + inventoryStatus: { + enabled: false, + inventory_id: null, + tags: [] + }, tpossTable: { columns: [ {name: 'name', align: 'left', label: 'Name', field: 'name'}, @@ -80,6 +90,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 +195,34 @@ 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() { + const tags = new Set(this.inventoryStatus.tags) + this.tposs.forEach(tpos => + (tpos.inventory_tags || []).forEach(tag => tags.add(tag)) + ) + return Array.from(tags).map(tag => ({label: tag, value: tag})) } }, methods: { closeFormDialog() { this.formDialog.show = false this.formDialog.data = { + use_inventory: this.inventoryStatus.enabled, + inventory_id: this.inventoryStatus.inventory_id, + inventory_tags: this.inventoryStatus.enabled + ? [...this.inventoryStatus.tags] + : [], tip_options: [], withdraw_between: 10, withdraw_time_option: '', @@ -213,19 +248,50 @@ 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 + if (!data.enabled) { + this.formDialog.data.use_inventory = false + return + } + if (!this.formDialog.data.inventory_id) { + this.formDialog.data.use_inventory = true + this.formDialog.data.inventory_id = data.inventory_id + this.formDialog.data.inventory_tags = [...data.tags] + } + } catch (error) { + console.error(error) + } + }, sendTposData() { const data = { ...this.formDialog.data, tip_options: this.formDialog.advanced.tips && this.formDialog.data.tip_options ? JSON.stringify( - this.formDialog.data.tip_options.map(str => parseInt(str)) - ) + this.formDialog.data.tip_options.map(str => parseInt(str)) + ) : JSON.stringify([]), 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 +351,50 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) }) }, + saveInventorySettings(tpos) { + const wallet = _.findWhere(this.g.user.wallets, {id: tpos.wallet}) + if (!wallet) return + const payload = { + use_inventory: this.inventoryStatus.enabled && tpos.use_inventory, + inventory_id: + tpos.inventory_id || this.inventoryStatus.inventory_id, + inventory_tags: tpos.inventory_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 = + tpos.inventory_id || this.inventoryStatus.inventory_id + if (!tpos.inventory_tags.length && this.inventoryStatus.tags.length) { + tpos.inventory_tags = [...this.inventoryStatus.tags] + } + } + this.saveInventorySettings(tpos) + }, + onInventoryTagsChange(tpos, tags) { + tpos.inventory_tags = tags || [] + this.saveInventorySettings(tpos) + }, deleteTpos(tposId) { const tpos = _.findWhere(this.tposs, {id: tposId}) @@ -506,6 +616,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..caa4563 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: [ @@ -245,6 +251,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), @@ -303,6 +324,42 @@ window.app = Vue.createApp({ this.addedAmount = 0.0 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.categories = this.extractCategories(this.items) + } + }, + async loadInventoryItems() { + if (!this.inventoryId) 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) { Quasar.Notify.create({ @@ -606,6 +663,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 +677,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 +1120,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..9a6f523 100644 --- a/tasks.py +++ b/tasks.py @@ -13,6 +13,70 @@ from .crud import get_tpos +async def _deduct_inventory_stock(payment: Payment, inventory_payload: dict) -> None: + if not ( + inventory_get_items_by_ids + and inventory_update_item + and create_inventory_update_log + and CreateInventoryUpdateLog + and UpdateSource + ): + return + + inventory_id = inventory_payload.get("inventory_id") + sold_items = inventory_payload.get("items") or [] + if not inventory_id or not sold_items: + return + + item_ids = [item.get("id") for item in sold_items if item.get("id")] + inventory_items = await inventory_get_items_by_ids(inventory_id, item_ids) + inventory_map = {item.id: item for item in inventory_items} + + for sold in sold_items: + item_id = sold.get("id") + quantity = sold.get("quantity") or 1 + item = inventory_map.get(item_id) + if not item or item.quantity_in_stock is None: + continue + + quantity_before = item.quantity_in_stock + deduction = min(quantity, quantity_before) + quantity_after = quantity_before - deduction + item.quantity_in_stock = quantity_after + + try: + await inventory_update_item(item) + await create_inventory_update_log( + CreateInventoryUpdateLog( + inventory_id=inventory_id, + item_id=item.id, + quantity_change=-deduction, + quantity_before=quantity_before, + quantity_after=quantity_after, + source=UpdateSource.SYSTEM, + idempotency_key=f"{payment.payment_hash}:{item.id}", + ) + ) + except Exception as exc: # pragma: no cover - log and continue + logger.warning( + f"tpos: failed to update inventory for item {item_id}: {exc!s}" + ) + +try: # inventory extension is optional + from ..inventory.crud import ( + create_inventory_update_log, + get_items_by_ids as inventory_get_items_by_ids, + update_item as inventory_update_item, + ) + from ..inventory.models import CreateInventoryUpdateLog, UpdateSource +except Exception: # pragma: no cover + inventory_get_items_by_ids = None + inventory_update_item = None + create_inventory_update_log = None + CreateInventoryUpdateLog = None + UpdateSource = None + + async def wait_for_paid_invoices(): invoice_queue = asyncio.Queue() register_invoice_listener(invoice_queue, "ext_tpos") @@ -64,6 +128,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, inventory_payload) + if not tip_amount: # no tip amount return diff --git a/templates/tpos/index.html b/templates/tpos/index.html index b328e3d..bda787a 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -131,6 +131,44 @@
TPoS
+
+
+ + + inventory extension must be enabled. + + +
+
+ +
+
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
+
+ + Using items from the Inventory extension. Tags + control which products appear in PoS. + +
TPoS