From 0e6313f9d318044973240b51ce48d7aaea862860 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 15 Apr 2026 18:01:19 +0100 Subject: [PATCH 01/11] init: first integration pass --- migrations.py | 12 +++ models.py | 38 ++++++++ services.py | 83 ++++++++++++++++ static/js/index.js | 13 ++- static/js/tpos.js | 165 +++++++++++++++++++++++++++++++- templates/tpos/dialogs.html | 119 +++++++++++++++++++++++ templates/tpos/index.html | 15 +++ views_api.py | 185 ++++++++++++++++++++++++++++++++++++ 8 files changed, 627 insertions(+), 3 deletions(-) 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..96524f1 100644 --- a/models.py +++ b/models.py @@ -76,6 +76,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 +117,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 +172,40 @@ class TposInvoiceResponse(BaseModel): extra: dict[str, Any] = Field(default_factory=dict) +class TposTab(BaseModel): + id: str + wallet: 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 + 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 + description: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + notes: dict[str, Any] | None = None + internal_memo: str | None = Field(None, max_length=512) + idempotency_key: str | None = None + + class LnurlCharge(BaseModel): id: str tpos_id: str diff --git a/services.py b/services.py index 9400c96..5a24b35 100644 --- a/services.py +++ b/services.py @@ -212,6 +212,89 @@ def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int: return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs) +def _create_internal_user_access_token(user_id: str) -> str: + return create_access_token({"sub": "", "usr": user_id}, token_expire_minutes=1) + + +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 fetch_tabs_for_tpos( + user_id: str, + wallet_id: str, + status: str | None = "open", + query: str | None = None, +) -> list[dict[str, Any]]: + 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}/tabs/api/v1/tabs", + headers={"Authorization": f"Bearer {access}"}, + ) + resp.raise_for_status() + tabs = resp.json() + if not isinstance(tabs, list): + return [] + filtered_tabs = [tab for tab in tabs if tab.get("wallet") == wallet_id] + if status: + filtered_tabs = [tab for tab in filtered_tabs if tab.get("status") == status] + if query: + needle = query.lower() + filtered_tabs = [ + tab + for tab in filtered_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() + ] + return filtered_tabs + + +async def create_tab_for_tpos(user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + access = _create_internal_user_access_token(user_id) + 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() + 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) + 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() + 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) + 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() + return resp.json() + + async def push_order_to_orders( user_id: str, payment, 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..83f58d8 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,21 @@ window.app = Vue.createApp({ paymentChecker: null, internalMemo: null }, + tabsDialog: { + show: false, + loading: false, + creating: false, + posting: false, + tabs: [], + selectedTabId: null, + query: '', + createMode: false, + newTab: { + name: '', + customer_name: '', + reference: '' + } + }, cashValidating: false, tipDialog: { show: false @@ -991,11 +1008,150 @@ window.app = Vue.createApp({ case 'btc_onchain': this.fiatMethod = 'checkout' break + case 'tab': + this.fiatMethod = 'checkout' + break } this._currencyResolver(method) this._currencyResolver = null } }, + 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) + const randomSuffix = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : Date.now().toString() + + 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: `tpos:${this.tposId}:${randomSuffix}` + } + }, + closeTabsDialog() { + this.tabsDialog.show = false + this.tabsDialog.createMode = false + this.tabsDialog.query = '' + this.tabsDialog.newTab = { + name: '', + customer_name: '', + reference: '' + } + }, + 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 || [] + 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.tabsDialog.newTab = { + name: '', + customer_name: '', + reference: '' + } + 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.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 openTabChargeDialog() { + await this.loadTabsForCharge() + this.tabsDialog.show = true + }, buildInvoiceParams() { const paymentAmount = this.paymentAmount !== null ? this.paymentAmount : this.amount @@ -1066,9 +1222,14 @@ window.app = Vue.createApp({ if ( this.fiatProvider || this.allowCashSettlement || - this.onchainEnabled + this.onchainEnabled || + this.tabsEnabled ) { 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 +1817,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/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index fc52599..50bae60 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -461,7 +461,126 @@
+
+ +
+ + Tab +
+
+
+ + + + +
Add to Tab
+ + +
+ + + + + + + + + + + + + + + + +
+ +
+ +
+ + + + +
+
+ + + + + +
+
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
> +
+
+ +
+
+ +
+