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/migrations.py b/migrations.py index 0df71bb..06a9614 100644 --- a/migrations.py +++ b/migrations.py @@ -290,3 +290,15 @@ async def m022_add_onchain_settings_and_payments(db: Database): updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); """) + + +async def m023_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 2996e4a..27d4016 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): @@ -76,6 +85,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): @@ -115,6 +126,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: @@ -168,6 +181,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 9400c96..f6ab0ec 100644 --- a/services.py +++ b/services.py @@ -1,6 +1,8 @@ +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, @@ -12,6 +14,19 @@ from loguru import logger from .helpers import from_csv, inventory_tags_to_list +from .models import Tpos + +_TAB_STATUSES = {"open", "suspended", "closed"} + + +async def get_tpos_owner_user_id(tpos: Tpos) -> str: + wallet = await get_wallet(tpos.wallet) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="TPoS is not ready for tabs integration.", + ) + return wallet.user async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> None: @@ -49,7 +64,7 @@ async def deduct_inventory_stock(wallet_id: str, inventory_payload: dict) -> Non async def get_default_inventory(user_id: str) -> dict[str, Any] | None: - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) + 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", @@ -76,7 +91,7 @@ async def get_inventory_items_for_tpos( 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) + 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", @@ -212,6 +227,162 @@ 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 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 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.", + ) + 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.", + ) + return user_id + + +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.", + ) + access = _create_internal_user_access_token(user_id) + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs", + headers={"Authorization": f"Bearer {access}"}, + ) + 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 + payload = resp.json() + 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]: + access = _create_internal_user_access_token(user_id) + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs", + headers={"Authorization": f"Bearer {access}"}, + json=payload, + ) + 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() + + +async def create_tab_charge_for_tpos( + user_id: str, + tab_id: str, + payload: dict[str, Any], +) -> dict[str, Any]: + access = _create_internal_user_access_token(user_id) + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs/{tab_id}/entries", + headers={"Authorization": f"Bearer {access}"}, + json=payload, + ) + 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() + + +async def fetch_single_tab_for_tpos(user_id: str, tab_id: str) -> dict[str, Any]: + access = _create_internal_user_access_token(user_id) + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs/{tab_id}", + headers={"Authorization": f"Bearer {access}"}, + ) + 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() + + +async def create_tab_settlement_for_tpos( + user_id: str, + tab_id: str, + payload: dict[str, Any], +) -> dict[str, Any]: + access = _create_internal_user_access_token(user_id) + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/tabs/api/v1/tabs/{tab_id}/settlements", + headers={"Authorization": f"Bearer {access}"}, + json=payload, + ) + 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() + + async def push_order_to_orders( user_id: str, payment, @@ -241,7 +412,7 @@ async def push_order_to_orders( "shipped": True, } - access = create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) + access = _create_internal_user_access_token(user_id) params = {} if base_url: params["base_url"] = base_url @@ -255,3 +426,22 @@ async def push_order_to_orders( ) except Exception as exc: logger.warning(f"tpos: failed to push order to orders: {exc}") + + +def _create_internal_user_access_token(user_id: str) -> str: + return create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) + + +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: + detail = "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/static/js/index.js b/static/js/index.js index 09f7a80..a88660d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -50,6 +50,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 = '' @@ -154,7 +156,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, @@ -309,7 +313,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} }, @@ -402,6 +408,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.js b/static/js/tpos.js index 0eb7696..479b742 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -45,6 +45,8 @@ window.app = Vue.createApp({ fiatProvider: null, allowCashSettlement: false, onchainEnabled: false, + tabsEnabled: false, + tabsAllowCreate: false, payInFiat: false, fiatMethod: 'checkout', atmPremium: tpos.withdraw_premium / 100, @@ -87,6 +89,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 @@ -258,6 +278,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) @@ -650,6 +673,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 } @@ -937,7 +961,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) @@ -991,11 +1015,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 @@ -1045,6 +1288,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, @@ -1066,9 +1312,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 { @@ -1656,6 +1908,8 @@ window.app = Vue.createApp({ this.fiatProvider = tpos.fiat_provider 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 diff --git a/tasks.py b/tasks.py index 47ad857..9dabca3 100644 --- a/tasks.py +++ b/tasks.py @@ -20,7 +20,9 @@ update_tpos_payment, ) from .services import ( + create_tab_settlement_for_tpos, deduct_inventory_stock, + ensure_tpos_tabs_access, fetch_onchain_balance, push_order_to_orders, ) @@ -169,6 +171,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 +204,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'" > + +
+ +
+ + Tab +
+
+
+ + + + +
+ + +
+ + + + + + + + + + + + +
+
+ Latest updated open tabs +
+ + + + + + + + +
+
No open tabs found.
+ +
+ +
+ +
+ + + + +
+
+ + + + + + +
+
diff --git a/templates/tpos/index.html b/templates/tpos/index.html index faec8c1..201658a 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -569,6 +569,21 @@
{{SITE_TITLE}} TPoS extension
> +
+
+ +
+
+ +
+