diff --git a/migrations.py b/migrations.py index 1ff6a7b..da20ce7 100644 --- a/migrations.py +++ b/migrations.py @@ -278,3 +278,14 @@ async def m019_add_receipt_sats_only(db: Database): ALTER TABLE tpos.pos ADD only_show_sats_on_bitcoin BOOLEAN DEFAULT true; """ ) + + +async def m020_add_remote_mode_toggle(db: Database): + """ + Add enable_remote option for cross-device invoice triggering. + """ + await db.execute( + """ + ALTER TABLE tpos.pos ADD enable_remote BOOLEAN DEFAULT false; + """ + ) diff --git a/models.py b/models.py index a2c0da4..ced51cd 100644 --- a/models.py +++ b/models.py @@ -61,6 +61,7 @@ class CreateTposData(BaseModel): lnaddress: bool = Field(False) lnaddress_cut: int | None = Field(0) enable_receipt_print: bool = Query(False) + enable_remote: bool = Query(False) business_name: str | None business_address: str | None business_vat_id: str | None @@ -95,6 +96,7 @@ class TposClean(BaseModel): inventory_omit_tags: str | None = None tip_options: str | None = None enable_receipt_print: bool + enable_remote: bool = False business_name: str | None = None business_address: str | None = None business_vat_id: str | None = None diff --git a/static/js/index.js b/static/js/index.js index 951886b..1ccd175 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -107,6 +107,7 @@ window.app = Vue.createApp({ lnaddress: false, lnaddress_cut: 2, enable_receipt_print: false, + enable_remote: false, only_show_sats_on_bitcoin: true, fiat: false, stripe_card_payments: false, @@ -243,6 +244,7 @@ window.app = Vue.createApp({ lnaddress: false, lnaddress_cut: 2, enable_receipt_print: false, + enable_remote: false, only_show_sats_on_bitcoin: true, fiat: false, stripe_card_payments: false, diff --git a/static/js/tpos.js b/static/js/tpos.js index ae6cc47..7606292 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -124,6 +124,7 @@ window.app = Vue.createApp({ totalfsat: 0, addedAmount: 0, enablePrint: false, + enableRemote: false, receiptData: null, orderReceipt: false, printDialog: { @@ -139,6 +140,9 @@ window.app = Vue.createApp({ this.$q.localStorage.getItem('lnbits.tpos.header') !== 'shown', categoryColors: {}, categoryColorIndex: 0, + remoteInvoiceWs: null, + remoteInvoiceReconnectTimer: null, + paymentWsByHash: {}, pastelColors: [ 'blue-5', 'green-5', @@ -261,6 +265,73 @@ window.app = Vue.createApp({ } }, methods: { + connectRemoteInvoiceWS() { + if (!this.enableRemote || !this.tposId) return + if (this.remoteInvoiceWs) return + + const url = new URL(window.location) + url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' + url.pathname = `/api/v1/ws/${this.tposId}` + const ws = new WebSocket(url) + this.remoteInvoiceWs = ws + + ws.onmessage = ({data}) => this.handleRemoteInvoiceMessage(data) + ws.onclose = () => { + this.remoteInvoiceWs = null + if (this.enableRemote) { + this.remoteInvoiceReconnectTimer = setTimeout(() => { + this.connectRemoteInvoiceWS() + }, 2000) + } + } + ws.onerror = err => { + console.warn('Remote websocket error:', err) + } + }, + disconnectRemoteInvoiceWS() { + if (this.remoteInvoiceReconnectTimer) { + clearTimeout(this.remoteInvoiceReconnectTimer) + this.remoteInvoiceReconnectTimer = null + } + if (this.remoteInvoiceWs) { + this.remoteInvoiceWs.close() + this.remoteInvoiceWs = null + } + }, + handleRemoteInvoiceMessage(rawData) { + let payload = null + try { + payload = JSON.parse(rawData) + } catch { + // Ignore non-JSON events from other TPoS websocket usages. + return + } + + if (payload.type !== 'invoice_created') return + if (!payload.payment_hash || !payload.payment_request) return + this.amount = payload.amount_fiat || this.amount + this.tipAmount = payload.tip_amount || this.tipAmount + this.exchangeRate = payload.exchange_rate || this.exchangeRate + + this.openInvoiceDialog(payload.payment_hash, payload.payment_request) + this.subscribeToPaymentWS(payload.payment_hash) + }, + openInvoiceDialog(paymentHash, paymentRequest) { + if ( + this.invoiceDialog.show && + this.invoiceDialog.data.payment_hash === paymentHash + ) { + return + } + this.invoiceDialog.data.payment_hash = paymentHash + this.invoiceDialog.data.payment_request = paymentRequest + this.invoiceDialog.show = true + this.readNfcTag() + this.invoiceDialog.dismissMsg = Quasar.Notify.create({ + timeout: 0, + message: 'Waiting for payment...' + }) + }, setColor(category) { if (!category || category.toLowerCase() === 'all') { return 'primary' @@ -338,6 +409,7 @@ window.app = Vue.createApp({ if (!cartItem) return this.$q .dialog({ + position: 'top', title: 'Set price', message: 'Update item price for this cart line', prompt: { @@ -366,6 +438,7 @@ window.app = Vue.createApp({ if (!cartItem) return this.$q .dialog({ + position: 'top', title: 'Set note', message: 'Add a note for this item', prompt: { @@ -838,28 +911,19 @@ window.app = Vue.createApp({ null, params ) + let paymentRequest = 'lightning:' + data.bolt11.toUpperCase() if ( data.extra.fiat_payment_request && !data.extra.fiat_payment_request.startsWith('pi_') ) { - this.invoiceDialog.data.payment_request = - data.extra.fiat_payment_request + paymentRequest = data.extra.fiat_payment_request } else if ( data.extra.fiat_payment_request && data.extra.fiat_payment_request.startsWith('pi_') ) { - this.invoiceDialog.data.payment_request = 'tap_to_pay' - } else { - this.invoiceDialog.data.payment_request = - 'lightning:' + data.bolt11.toUpperCase() + paymentRequest = 'tap_to_pay' } - this.invoiceDialog.data.payment_hash = data.payment_hash - this.invoiceDialog.show = true - this.readNfcTag() - this.invoiceDialog.dismissMsg = Quasar.Notify.create({ - timeout: 0, - message: 'Waiting for payment...' - }) + this.openInvoiceDialog(data.payment_hash, paymentRequest) this.subscribeToPaymentWS(data.payment_hash) } catch (error) { console.error(error) @@ -867,11 +931,13 @@ window.app = Vue.createApp({ } }, subscribeToPaymentWS(paymentHash) { + if (this.paymentWsByHash[paymentHash]) return try { const url = new URL(window.location) url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' url.pathname = `/api/v1/ws/${paymentHash}` const ws = new WebSocket(url) + this.paymentWsByHash[paymentHash] = ws ws.onmessage = async ({data}) => { const payment = JSON.parse(data) if (payment.pending === false) { @@ -889,6 +955,9 @@ window.app = Vue.createApp({ ws.close() } } + ws.onclose = () => { + delete this.paymentWsByHash[paymentHash] + } } catch (err) { console.warn(err) LNbits.utils.notifyApiError(err) @@ -1302,6 +1371,7 @@ window.app = Vue.createApp({ this.tposLNaddress = tpos.lnaddress this.tposLNaddressCut = tpos.lnaddress_cut this.enablePrint = tpos.enable_receipt_print + this.enableRemote = Boolean(tpos.enable_remote) this.fiatProvider = tpos.fiat_provider this.tip_options = tpos.tip_options == 'null' ? null : tpos.tip_options @@ -1334,6 +1404,11 @@ window.app = Vue.createApp({ if (this.headerElement) { this.headerElement.style.display = this.headerHidden ? 'none' : '' } + this.connectRemoteInvoiceWS() + }, + beforeUnmount() { + this.disconnectRemoteInvoiceWS() + Object.values(this.paymentWsByHash).forEach(ws => ws.close()) }, onMounted() { if (!this.headerElement) { diff --git a/templates/tpos/index.html b/templates/tpos/index.html index ffef6a0..79ad49a 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -488,12 +488,20 @@
{{SITE_TITLE}} TPoS extension
label="Enable selling BTC (ATM)" > + +
+
+ +