Skip to content

Commit b9cdfdc

Browse files
feat: remote listener for multiple device setup (#198)
* feat: create remote listener for displaying invoices across devices. Useful for a 2 device merchant setup. * load price and note edit dialogs from the top * make * Adds more info for remote dialog (#199) * pass info for remote dialog * populate dialog with value info --------- Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
1 parent 2514b26 commit b9cdfdc

6 files changed

Lines changed: 133 additions & 15 deletions

File tree

‎migrations.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,14 @@ async def m019_add_receipt_sats_only(db: Database):
278278
ALTER TABLE tpos.pos ADD only_show_sats_on_bitcoin BOOLEAN DEFAULT true;
279279
"""
280280
)
281+
282+
283+
async def m020_add_remote_mode_toggle(db: Database):
284+
"""
285+
Add enable_remote option for cross-device invoice triggering.
286+
"""
287+
await db.execute(
288+
"""
289+
ALTER TABLE tpos.pos ADD enable_remote BOOLEAN DEFAULT false;
290+
"""
291+
)

‎models.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class CreateTposData(BaseModel):
6161
lnaddress: bool = Field(False)
6262
lnaddress_cut: int | None = Field(0)
6363
enable_receipt_print: bool = Query(False)
64+
enable_remote: bool = Query(False)
6465
business_name: str | None
6566
business_address: str | None
6667
business_vat_id: str | None
@@ -95,6 +96,7 @@ class TposClean(BaseModel):
9596
inventory_omit_tags: str | None = None
9697
tip_options: str | None = None
9798
enable_receipt_print: bool
99+
enable_remote: bool = False
98100
business_name: str | None = None
99101
business_address: str | None = None
100102
business_vat_id: str | None = None

‎static/js/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ window.app = Vue.createApp({
107107
lnaddress: false,
108108
lnaddress_cut: 2,
109109
enable_receipt_print: false,
110+
enable_remote: false,
110111
only_show_sats_on_bitcoin: true,
111112
fiat: false,
112113
stripe_card_payments: false,
@@ -243,6 +244,7 @@ window.app = Vue.createApp({
243244
lnaddress: false,
244245
lnaddress_cut: 2,
245246
enable_receipt_print: false,
247+
enable_remote: false,
246248
only_show_sats_on_bitcoin: true,
247249
fiat: false,
248250
stripe_card_payments: false,

‎static/js/tpos.js‎

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ window.app = Vue.createApp({
124124
totalfsat: 0,
125125
addedAmount: 0,
126126
enablePrint: false,
127+
enableRemote: false,
127128
receiptData: null,
128129
orderReceipt: false,
129130
printDialog: {
@@ -139,6 +140,9 @@ window.app = Vue.createApp({
139140
this.$q.localStorage.getItem('lnbits.tpos.header') !== 'shown',
140141
categoryColors: {},
141142
categoryColorIndex: 0,
143+
remoteInvoiceWs: null,
144+
remoteInvoiceReconnectTimer: null,
145+
paymentWsByHash: {},
142146
pastelColors: [
143147
'blue-5',
144148
'green-5',
@@ -261,6 +265,73 @@ window.app = Vue.createApp({
261265
}
262266
},
263267
methods: {
268+
connectRemoteInvoiceWS() {
269+
if (!this.enableRemote || !this.tposId) return
270+
if (this.remoteInvoiceWs) return
271+
272+
const url = new URL(window.location)
273+
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
274+
url.pathname = `/api/v1/ws/${this.tposId}`
275+
const ws = new WebSocket(url)
276+
this.remoteInvoiceWs = ws
277+
278+
ws.onmessage = ({data}) => this.handleRemoteInvoiceMessage(data)
279+
ws.onclose = () => {
280+
this.remoteInvoiceWs = null
281+
if (this.enableRemote) {
282+
this.remoteInvoiceReconnectTimer = setTimeout(() => {
283+
this.connectRemoteInvoiceWS()
284+
}, 2000)
285+
}
286+
}
287+
ws.onerror = err => {
288+
console.warn('Remote websocket error:', err)
289+
}
290+
},
291+
disconnectRemoteInvoiceWS() {
292+
if (this.remoteInvoiceReconnectTimer) {
293+
clearTimeout(this.remoteInvoiceReconnectTimer)
294+
this.remoteInvoiceReconnectTimer = null
295+
}
296+
if (this.remoteInvoiceWs) {
297+
this.remoteInvoiceWs.close()
298+
this.remoteInvoiceWs = null
299+
}
300+
},
301+
handleRemoteInvoiceMessage(rawData) {
302+
let payload = null
303+
try {
304+
payload = JSON.parse(rawData)
305+
} catch {
306+
// Ignore non-JSON events from other TPoS websocket usages.
307+
return
308+
}
309+
310+
if (payload.type !== 'invoice_created') return
311+
if (!payload.payment_hash || !payload.payment_request) return
312+
this.amount = payload.amount_fiat || this.amount
313+
this.tipAmount = payload.tip_amount || this.tipAmount
314+
this.exchangeRate = payload.exchange_rate || this.exchangeRate
315+
316+
this.openInvoiceDialog(payload.payment_hash, payload.payment_request)
317+
this.subscribeToPaymentWS(payload.payment_hash)
318+
},
319+
openInvoiceDialog(paymentHash, paymentRequest) {
320+
if (
321+
this.invoiceDialog.show &&
322+
this.invoiceDialog.data.payment_hash === paymentHash
323+
) {
324+
return
325+
}
326+
this.invoiceDialog.data.payment_hash = paymentHash
327+
this.invoiceDialog.data.payment_request = paymentRequest
328+
this.invoiceDialog.show = true
329+
this.readNfcTag()
330+
this.invoiceDialog.dismissMsg = Quasar.Notify.create({
331+
timeout: 0,
332+
message: 'Waiting for payment...'
333+
})
334+
},
264335
setColor(category) {
265336
if (!category || category.toLowerCase() === 'all') {
266337
return 'primary'
@@ -338,6 +409,7 @@ window.app = Vue.createApp({
338409
if (!cartItem) return
339410
this.$q
340411
.dialog({
412+
position: 'top',
341413
title: 'Set price',
342414
message: 'Update item price for this cart line',
343415
prompt: {
@@ -366,6 +438,7 @@ window.app = Vue.createApp({
366438
if (!cartItem) return
367439
this.$q
368440
.dialog({
441+
position: 'top',
369442
title: 'Set note',
370443
message: 'Add a note for this item',
371444
prompt: {
@@ -838,40 +911,33 @@ window.app = Vue.createApp({
838911
null,
839912
params
840913
)
914+
let paymentRequest = 'lightning:' + data.bolt11.toUpperCase()
841915
if (
842916
data.extra.fiat_payment_request &&
843917
!data.extra.fiat_payment_request.startsWith('pi_')
844918
) {
845-
this.invoiceDialog.data.payment_request =
846-
data.extra.fiat_payment_request
919+
paymentRequest = data.extra.fiat_payment_request
847920
} else if (
848921
data.extra.fiat_payment_request &&
849922
data.extra.fiat_payment_request.startsWith('pi_')
850923
) {
851-
this.invoiceDialog.data.payment_request = 'tap_to_pay'
852-
} else {
853-
this.invoiceDialog.data.payment_request =
854-
'lightning:' + data.bolt11.toUpperCase()
924+
paymentRequest = 'tap_to_pay'
855925
}
856-
this.invoiceDialog.data.payment_hash = data.payment_hash
857-
this.invoiceDialog.show = true
858-
this.readNfcTag()
859-
this.invoiceDialog.dismissMsg = Quasar.Notify.create({
860-
timeout: 0,
861-
message: 'Waiting for payment...'
862-
})
926+
this.openInvoiceDialog(data.payment_hash, paymentRequest)
863927
this.subscribeToPaymentWS(data.payment_hash)
864928
} catch (error) {
865929
console.error(error)
866930
LNbits.utils.notifyApiError(error)
867931
}
868932
},
869933
subscribeToPaymentWS(paymentHash) {
934+
if (this.paymentWsByHash[paymentHash]) return
870935
try {
871936
const url = new URL(window.location)
872937
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
873938
url.pathname = `/api/v1/ws/${paymentHash}`
874939
const ws = new WebSocket(url)
940+
this.paymentWsByHash[paymentHash] = ws
875941
ws.onmessage = async ({data}) => {
876942
const payment = JSON.parse(data)
877943
if (payment.pending === false) {
@@ -889,6 +955,9 @@ window.app = Vue.createApp({
889955
ws.close()
890956
}
891957
}
958+
ws.onclose = () => {
959+
delete this.paymentWsByHash[paymentHash]
960+
}
892961
} catch (err) {
893962
console.warn(err)
894963
LNbits.utils.notifyApiError(err)
@@ -1302,6 +1371,7 @@ window.app = Vue.createApp({
13021371
this.tposLNaddress = tpos.lnaddress
13031372
this.tposLNaddressCut = tpos.lnaddress_cut
13041373
this.enablePrint = tpos.enable_receipt_print
1374+
this.enableRemote = Boolean(tpos.enable_remote)
13051375
this.fiatProvider = tpos.fiat_provider
13061376

13071377
this.tip_options = tpos.tip_options == 'null' ? null : tpos.tip_options
@@ -1334,6 +1404,11 @@ window.app = Vue.createApp({
13341404
if (this.headerElement) {
13351405
this.headerElement.style.display = this.headerHidden ? 'none' : ''
13361406
}
1407+
this.connectRemoteInvoiceWS()
1408+
},
1409+
beforeUnmount() {
1410+
this.disconnectRemoteInvoiceWS()
1411+
Object.values(this.paymentWsByHash).forEach(ws => ws.close())
13371412
},
13381413
onMounted() {
13391414
if (!this.headerElement) {

‎templates/tpos/index.html‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,12 +488,20 @@ <h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} TPoS extension</h6>
488488
label="Enable selling BTC (ATM)"
489489
></q-checkbox>
490490
</div>
491+
</div>
492+
<div class="row">
491493
<div class="col">
492494
<q-checkbox
493495
v-model="formDialog.data.enable_receipt_print"
494496
label="Enable printing (experimental)"
495497
></q-checkbox>
496498
</div>
499+
<div class="col">
500+
<q-checkbox
501+
v-model="formDialog.data.enable_remote"
502+
label="Enable remote (to trigger payments on external device)"
503+
></q-checkbox>
504+
</div>
497505
</div>
498506
<template v-if="formDialog.data.enable_receipt_print">
499507
<p class="text-caption">

‎views_api.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,20 +246,40 @@ async def api_tpos_create_invoice(
246246
fiat_provider=tpos.fiat_provider if data.pay_in_fiat else None,
247247
)
248248
payment = await create_payment_request(tpos.wallet, invoice_data)
249+
payment_request_for_display = "lightning:" + payment.bolt11.upper()
250+
fiat_payment_request = payment.extra.get("fiat_payment_request")
251+
if fiat_payment_request and not fiat_payment_request.startswith("pi_"):
252+
payment_request_for_display = fiat_payment_request
253+
elif fiat_payment_request and fiat_payment_request.startswith("pi_"):
254+
payment_request_for_display = "tap_to_pay"
255+
256+
if tpos.enable_remote:
257+
payload = {
258+
"type": "invoice_created",
259+
"tpos_id": tpos_id,
260+
"payment_hash": payment.payment_hash,
261+
"payment_request": payment_request_for_display,
262+
"paid_in_fiat": data.pay_in_fiat,
263+
"amount_fiat": data.amount_fiat,
264+
"tip_amount": data.tip_amount,
265+
"exchange_rate": data.exchange_rate if data.exchange_rate else None,
266+
}
267+
await websocket_updater(tpos_id, json.dumps(payload))
268+
249269
if (invoice_data.extra or {}).get("fiat_method") == "terminal":
250270
pi_id = payment.extra.get("fiat_checking_id")
251271
client_secret = payment.extra.get("fiat_payment_request")
252272
if pi_id and client_secret:
253273
amount_minor = round(amount * 100)
254-
payload = TapToPay(
274+
tap_to_pay_payload = TapToPay(
255275
payment_intent_id=pi_id,
256276
client_secret=client_secret,
257277
currency=invoice_data.unit.lower(),
258278
amount=amount_minor,
259279
tpos_id=tpos_id,
260280
payment_hash=payment.payment_hash,
261281
)
262-
await websocket_updater(tpos_id, str(payload))
282+
await websocket_updater(tpos_id, json.dumps(tap_to_pay_payload.dict()))
263283
return payment
264284

265285
except Exception as exc:

0 commit comments

Comments
 (0)