From 9430bf1870ed3e84c6b51c54167b7a217c9b0804 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 9 Apr 2024 11:54:00 +0100 Subject: [PATCH 01/18] admin setting --- crud.py | 5 ++-- migrations.py | 20 +++++++++++--- models.py | 36 +++++++------------------ templates/tpos/index.html | 56 +++++++++++++++++++++++++++++++++++++-- views_api.py | 13 ++++++--- 5 files changed, 91 insertions(+), 39 deletions(-) diff --git a/crud.py b/crud.py index bbc12a4..0a37935 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,11 @@ from typing import List, Optional, Union +from loguru import logger + from lnbits.helpers import urlsafe_short_hash from . import db -from .models import CreateTposData, TPoS, TPoSClean, LNURLCharge -from loguru import logger +from .models import CreateTposData, LNURLCharge, TPoS, TPoSClean async def get_current_timestamp(): diff --git a/migrations.py b/migrations.py index 60ff7f0..3f65309 100644 --- a/migrations.py +++ b/migrations.py @@ -78,7 +78,7 @@ async def m005_initial(db): Initial withdraws table. """ await db.execute( - f""" + """ CREATE TABLE tpos.withdraws ( id TEXT PRIMARY KEY, tpos_id TEXT NOT NULL, @@ -108,10 +108,22 @@ async def m007_atm_premium(db): await db.execute("ALTER TABLE tpos.pos ADD COLUMN withdrawpremium FLOAT;") - async def m008_atm_time_option_and_pin_toggle(db): """ Add a time mins/sec and pin toggle """ - await db.execute("ALTER TABLE tpos.pos ADD COLUMN withdrawtimeopt TEXT DEFAULT 'mins';") - await db.execute("ALTER TABLE tpos.pos ADD COLUMN withdrawpindisabled BOOL NOT NULL DEFAULT false;") + await db.execute( + "ALTER TABLE tpos.pos ADD COLUMN withdrawtimeopt TEXT DEFAULT 'mins';" + ) + await db.execute( + "ALTER TABLE tpos.pos ADD COLUMN withdrawpindisabled BOOL NOT NULL DEFAULT false;" + ) + + +async def m009_tax_inclusive(db): + """ + Add tax_inclusive column + """ + await db.execute( + "ALTER TABLE tpos.pos ADD COLUMN tax_inclusive BOOL NOT NULL DEFAULT true;" + ) diff --git a/models.py b/models.py index c41fc83..f00d7ab 100644 --- a/models.py +++ b/models.py @@ -1,5 +1,5 @@ from sqlite3 import Row -from typing import Optional, Any, List +from typing import List, Optional from fastapi import Request from lnurl import Lnurl, LnurlWithdrawResponse @@ -22,32 +22,7 @@ class CreateTposData(BaseModel): withdrawbtwn: int = Field(10, ge=1) withdrawpremium: float = Field(None) withdrawpindisabled: bool = Field(False) - - -class TPoS(BaseModel): - id: str - wallet: str - name: str - currency: str - tip_options: Optional[str] - tip_wallet: Optional[str] - withdrawlimit: Optional[int] - withdrawpin: Optional[int] - withdrawamt: int - withdrawtime: int - withdrawtimeopt: Optional[str] - withdrawbtwn: int - withdrawpremium: Optional[float] - withdrawpindisabled: Optional[bool] - items: Optional[str] - - @classmethod - def from_row(cls, row: Row) -> "TPoS": - return cls(**dict(row)) - - @property - def withdrawamtposs(self) -> int: - return self.withdrawlimit - self.withdrawamt if self.withdrawlimit else 0 + tax_inclusive: bool = Field(True) class TPoSClean(BaseModel): @@ -63,6 +38,7 @@ class TPoSClean(BaseModel): withdrawpremium: Optional[float] withdrawpindisabled: Optional[bool] items: Optional[str] + tax_inclusive: bool @classmethod def from_row(cls, row: Row) -> "TPoSClean": @@ -73,6 +49,12 @@ def withdrawamtposs(self) -> int: return self.withdrawlimit - self.withdrawamt if self.withdrawlimit else 0 +class TPoS(TPoSClean, BaseModel): + wallet: str + tip_wallet: Optional[str] + withdrawpin: Optional[int] + + class LNURLCharge(BaseModel): id: str tpos_id: str diff --git a/templates/tpos/index.html b/templates/tpos/index.html index e9dfbba..f3a9bd8 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -229,7 +229,6 @@
TPoS
-
TPoS :columns="itemsTable.columns" :pagination.sync="itemsTable.pagination" > + - + + + + + Tax Inclusive means the unit price includes tax. (default) +
+ Tax Exclusive means tax is applied on top of the unit price. +
+ + + + +
+
+
{{SITE_TITLE}} TPoS extension dense v-model.number="itemDialog.data.tax" label="Tax %" + :hint="`${itemDialog.taxInclusive ? 'Tax is included on unit price' : 'Tax is added on top of unit price'}. You can change behaviour on TPoS settings.`" > label: 'Price', field: 'price' }, + { + name: 'tax', + align: 'left', + label: 'Tax', + field: 'tax' + }, { name: 'disabled', align: 'left', @@ -1070,6 +1115,7 @@
openItemDialog(id) { const [tposId, itemId] = id.split(':') const tpos = _.findWhere(this.tposs, {id: tposId}) + if (itemId) { const item = tpos.itemsMap.get(id) this.itemDialog.data = { @@ -1079,6 +1125,7 @@
} else { this.itemDialog.data.tpos = tposId } + this.itemDialog.taxInclusive = tpos.tax_inclusive this.itemDialog.data.currency = tpos.currency this.itemDialog.show = true }, @@ -1139,6 +1186,10 @@
}) }, updateTposItems(tposId, wallet, data) { + const tpos = _.findWhere(this.tposs, {id: tposId}) + if (tpos.tax_inclusive != this.taxInclusive) { + data.tax_inclusive = this.taxInclusive + } LNbits.api .request( 'PUT', @@ -1150,6 +1201,7 @@
this.tposs = _.reject(this.tposs, obj => { return obj.id == tposId }) + console.log(response.data) this.tposs.push(mapTPoS(response.data)) this.closeItemDialog() }) diff --git a/views_api.py b/views_api.py index 9d87225..1ae5bac 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,5 @@ -from http import HTTPStatus import json +from http import HTTPStatus import httpx from fastapi import Depends, Query, Request @@ -21,15 +21,20 @@ from . import tpos_ext from .crud import ( create_tpos, - update_tpos, delete_tpos, + get_lnurlcharge, get_tpos, get_tposs, start_lnurlcharge, - get_lnurlcharge, update_lnurlcharge, + update_tpos, +) +from .models import ( + CreateTposData, + CreateUpdateItemData, + LNURLCharge, + PayLnurlWData, ) -from .models import CreateTposData, PayLnurlWData, LNURLCharge, CreateUpdateItemData @tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK) From a3a5041dd4dde9354d9424fe6571e0192fd6ae3a Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 11 Apr 2024 10:56:29 +0100 Subject: [PATCH 02/18] tax display --- templates/tpos/tpos.html | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html index 665965e..4da4786 100644 --- a/templates/tpos/tpos.html +++ b/templates/tpos/tpos.html @@ -288,7 +288,11 @@
- ${item.formattedPrice} +
+ ${item.formattedPrice} + ${item.tax}% + ${item.formattedPrice} +
+
${item.title}
-
${item.formattedPrice}
+
+ ${item.formattedPrice} + (tax ${taxInclusive ? 'incl.' : 'excl.'} ${item.tax}%) + +
quantity: quantity }) } - this.total = this.total + item.price * quantity + this.total = this.total + this.calculateItemPrice(item, quantity) + // this.total = this.total + item.price * quantity }, removeFromCart(item, quantity = 1) { let item_quantity = this.cart.get(item.id).quantity @@ -887,7 +898,14 @@
quantity: this.cart.get(item.id).quantity - quantity }) } - this.total = this.total - item.price * quantity + // this.total = this.total - item.price * quantity + this.total = this.total - this.calculateItemPrice(item, quantity) + }, + calculateItemPrice(item, qty){ + if(!item.tax || this.taxInclusive) return item.price * qty + + // add tax to price + return item.price * (1 + item.tax * 0.01) * qty }, clearCart() { this.cart.clear() @@ -1325,6 +1343,8 @@
var getRates = this.getRates getRates() this.pinDisabled = JSON.parse('{{ tpos.withdrawpindisabled | tojson }}') + this.taxInclusive = JSON.parse('{{ tpos.tax_inclusive | tojson }}') + this.tip_options = '{{ tpos.tip_options | tojson }}' == 'null' ? null From a3e00ccbc83b2e6d29a4bc0f0174764d4424e13d Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 11 Apr 2024 12:39:00 +0100 Subject: [PATCH 03/18] include tax scheme on details add some UI sugar --- templates/tpos/tpos.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html index 4da4786..3a122fa 100644 --- a/templates/tpos/tpos.html +++ b/templates/tpos/tpos.html @@ -350,8 +350,8 @@
-
+
+
+ + +
> - - - - - - - Pay in wallet - -
-

${ amountWithTipFormatted }

-
- ${ fsat } - sat - ( + ${ tipAmountFormatted } tip) -
- - - NFC supported - - NFC not supported -
-
- Copy invoice - Close -
-
-
- - - -
- Would you like to leave a tip? -
-
- ${ tip }% - -
- - - Ok -
-
-
- No, thanks - Close -
-
-
- - - - - - -
-

- {{ tpos.name }}
{{ request.url }} -

-
-
- Copy URL - Close -
-
-
- - - - - - - - - - - - - - - No paid invoices - - - - - ${payment.amount / 1000} sats - Hash: ${payment.checking_id.slice(0, 30)}... - - - ${payment.dateFrom} - - - - - - - - - - -
Withdraw PIN
-
- - - -
- -
-
-
-
-
+ {% include "tpos/dialogs.html" %} {% endblock %} {% block styles %} @@ -678,743 +502,30 @@
} {% endblock %} {% block scripts %} - + - { - name: 'id', - align: 'left', - label: 'ID', - field: 'id' - }, - { - name: 'title', - align: 'left', - label: 'Title', - field: 'title' - }, - { - name: 'price', - align: 'left', - label: 'Price', - field: 'price' - }, - { - name: 'disabled', - align: 'left', - label: 'Disabled', - field: 'disabled' - } - ], - pagination: { - rowsPerPage: 10 - } - }, - monochrome: this.$q.localStorage.getItem('lnbits.tpos.color') || false, - showPoS: true, - cartDrawer: this.$q.screen.width > 1200, - searchTerm: '', - categoryFilter: '', - cart: new Map(), - denomIsSats: '{{ tpos.currency }}' == 'sats' - } - }, - computed: { - amount: function () { - if (!this.stack.length) return 0.0 - return ( - this.stack.reduce((acc, dig) => acc * 10 + dig, 0) * - (this.currency == 'sats' ? 1 : 0.01) - ) - }, - amountFormatted: function () { - return this.formatAmount(this.amount, this.currency) - }, - totalFormatted() { - return this.formatAmount(this.total, this.currency) - }, - amountWithTipFormatted: function () { - return this.formatAmount(this.amount + this.tipAmount, this.currency) - }, - sat: function () { - if (!this.exchangeRate) return 0 - return Math.ceil(this.amount * this.exchangeRate) - }, - totalSat: function () { - if (!this.exchangeRate) return 0 - return Math.ceil(this.total * this.exchangeRate) - }, - tipAmountSat: function () { - if (!this.exchangeRate) return 0 - return Math.ceil(this.tipAmount * this.exchangeRate) - }, - tipAmountFormatted: function () { - return LNbits.utils.formatSat(this.tipAmountSat) - }, - fsat: function () { - return LNbits.utils.formatSat(this.sat) - }, - totalfsat: function () { - return LNbits.utils.formatSat(this.totalSat) - }, - roundToSugestion() { - switch (true) { - case this.amount > 50: - toNext = 10 - break - case this.amount > 6: - toNext = 5 - break - case this.amount > 2.5: - toNext = 1 - break - default: - toNext = 0.5 - break - } - - return Math.ceil(this.amount / toNext) * toNext - }, - fullScreenIcon() { - return this.isFullScreen ? 'fullscreen_exit' : 'fullscreen' - }, - filteredItems() { - // filter out disabled items - let items = this.items.filter(item => !item.disabled) - // if searchTerm entered, filter out items that don't match - if (this.searchTerm) { - items = items.filter(item => { - return item.title - .toLowerCase() - .includes(this.searchTerm.toLowerCase()) - }) - } - // if categoryFilter entered, filter out items that don't match - if (this.categoryFilter) { - items = items.filter(item => { - return item.categories - .map(c => c.toLowerCase()) - .includes(this.categoryFilter.toLowerCase()) - }) - } - return items - }, - drawerWidth() { - return this.$q.screen.width < 500 ? 375 : 450 - } - }, - methods: { - addAmount() { - this.total = +(this.total + this.amount).toFixed(2) - this.stack = [] - }, - cancelAddAmount() { - this.total = 0.0 - this.stack = [] - }, - addToCart(item, quantity = 1) { - if (this.cart.has(item.id)) { - this.cart.set(item.id, { - ...this.cart.get(item.id), - quantity: this.cart.get(item.id).quantity + quantity - }) - } else { - this.cart.set(item.id, { - ...item, - quantity: quantity - }) - } - this.total = this.total + this.calculateItemPrice(item, quantity) - // this.total = this.total + item.price * quantity - }, - removeFromCart(item, quantity = 1) { - let item_quantity = this.cart.get(item.id).quantity - if (item_quantity == 1 || item_quantity == quantity) { - this.cart.delete(item.id) - } else { - this.cart.set(item.id, { - ...this.cart.get(item.id), - quantity: this.cart.get(item.id).quantity - quantity - }) - } - // this.total = this.total - item.price * quantity - this.total = this.total - this.calculateItemPrice(item, quantity) - }, - calculateItemPrice(item, qty){ - if(!item.tax || this.taxInclusive) return item.price * qty - - // add tax to price - return item.price * (1 + item.tax * 0.01) * qty - }, - clearCart() { - this.cart.clear() - this.total = 0.0 - }, - atm() { - if (this.atmPremium > 0) { - this.exchangeRate = this.exchangeRate / (1 + this.atmPremium) - } - if (this.withdrawpinopen != 0) { - this.atmPin = this.withdrawpinopen - this.atmSubmit() - return - } - if (this.withdrawamtposs > 0) { - this.atmBox = true - } - }, - atmSubmit() { - self = this - LNbits.api - .request('GET', `/tpos/api/v1/atm/` + this.tposId + `/` + this.atmPin) - .then(function (res) { - self.atmToken = res.data.id - if (res.data.claimed == false) { - self.atmBox = false - self.atmMode = true - } - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - atmGetWithdraw: function () { - self = this - var dialog = this.invoiceDialog - LNbits.api - .request( - 'GET', - `/tpos/api/v1/atm/withdraw/` + this.atmToken + `/` + this.sat - ) - .then(function (res) { - lnurl = res.data.lnurl - dialog.data = {payment_request: lnurl} - dialog.show = true - self.readNfcTag() - dialog.dismissMsg = self.$q.notify({ - timeout: 0, - message: 'Withdraw...' - }) - if (location.protocol !== 'http:') { - self.withdrawUrl = - 'wss://' + - document.domain + - ':' + - location.port + - '/api/v1/ws/' + - self.atmToken - } else { - self.withdrawUrl = - 'ws://' + - document.domain + - ':' + - location.port + - '/api/v1/ws/' + - self.atmToken - } - this.connectionWithdraw = new WebSocket(self.withdrawUrl) - this.connectionWithdraw.onmessage = e => { - if (e.data == 'paid') { - dialog.show = false - self.atmPin = null - self.atmToken = '' - self.complete.show = true - self.atmMode = false - this.connectionWithdraw.close() - } - } - this.getRates() - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - setRounding() { - this.rounding = true - this.tipRounding = this.roundToSugestion - this.$nextTick(() => this.$refs.inputRounding.focus()) - }, - calculatePercent() { - let change = ((this.tipRounding - this.amount) / this.amount) * 100 - if (change < 0) { - this.$q.notify({ - type: 'warning', - message: 'Amount with tip must be greater than initial amount.' - }) - this.tipRounding = this.roundToSugestion - return - } - this.processTipSelection(change) - }, - closeInvoiceDialog: function () { - this.stack = [] - this.tipAmount = 0.0 - var dialog = this.invoiceDialog - setTimeout(function () { - clearInterval(dialog.paymentChecker) - dialog.dismissMsg() - }, 3000) - }, - processTipSelection: function (selectedTipOption) { - this.tipDialog.show = false - - if (!selectedTipOption) { - this.tipAmount = 0.0 - return this.showInvoice() - } - - this.tipAmount = (selectedTipOption / 100) * this.amount - this.showInvoice() - }, - submitForm: function () { - if (this.total != 0.0) { - if (this.currency == 'sats') { - this.stack = Array.from(String(Math.ceil(this.total), Number)) - } else { - this.stack = Array.from(String(Math.ceil(this.total * 100)), Number) - } - } - if (!this.exchangeRate || this.exchangeRate == 0 || this.sat == 0) { - this.$q.notify({ - type: 'negative', - message: - 'Exchange rate not available, or wrong value. Please try again later.' - }) - return - } - - if (this.tip_options && this.tip_options.length) { - this.rounding = false - this.tipRounding = null - this.showTipModal() - } else { - this.showInvoice() - } - }, - showTipModal: function () { - if (!this.atmMode) { - this.tipDialog.show = true - } else { - this.showInvoice() - } - }, - showInvoice: function () { - var self = this - if (self.atmMode) { - this.atmGetWithdraw() - } else { - var dialog = this.invoiceDialog - let params = { - amount: this.sat, - memo: this.amountFormatted, - tipAmount: this.tipAmountSat - } - if (this.cart.size) { - let details = [...this.cart.values()].map(item => { - return { - price: item.price, - formattedPrice: item.formattedPrice, - quantity: item.quantity, - title: item.title, - tax: item.tax - } - }) - - params.details = JSON.stringify({ - currency: this.currency, - exchangeRate: this.exchangeRate, - items: details, - taxIncluded: this.taxInclusive, - }) - } - - axios - .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, { - params: {...params} - }) - .then(function (response) { - dialog.data = response.data - dialog.show = true - self.readNfcTag() - - dialog.dismissMsg = self.$q.notify({ - timeout: 0, - message: 'Waiting for payment...' - }) - - dialog.paymentChecker = setInterval(function () { - axios - .get( - '/tpos/api/v1/tposs/' + - self.tposId + - '/invoices/' + - response.data.payment_hash - ) - .then(function (res) { - if (res.data.paid) { - clearInterval(dialog.paymentChecker) - dialog.dismissMsg() - dialog.show = false - self.clearCart() - - self.complete.show = true - } - }) - }, 3000) - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - } - }, - readNfcTag: function () { - try { - const self = this - - if (typeof NDEFReader == 'undefined') { - console.debug('NFC not supported on this device or browser.') - return - } - - const ndef = new NDEFReader() - - const readerAbortController = new AbortController() - readerAbortController.signal.onabort = event => { - console.debug('All NFC Read operations have been aborted.') - } - - this.nfcTagReading = true - this.$q.notify({ - message: this.atmMode - ? 'Tap your NFC tag to withdraw with LNURLp.' - : 'Tap your NFC tag to pay this invoice with LNURLw.' - }) - - return ndef.scan({signal: readerAbortController.signal}).then(() => { - ndef.onreadingerror = () => { - self.nfcTagReading = false - - this.$q.notify({ - type: 'negative', - message: 'There was an error reading this NFC tag.' - }) - - readerAbortController.abort() - } - - ndef.onreading = ({message}) => { - //Decode NDEF data from tag - const textDecoder = new TextDecoder('utf-8') - - const record = message.records.find(el => { - const payload = textDecoder.decode(el.data) - return payload.toUpperCase().indexOf('LNURL') !== -1 - }) - - const lnurl = textDecoder.decode(record.data) - - //User feedback, show loader icon - self.nfcTagReading = false - if (self.atmMode) { - const URL = lnurl.replace('lnurlp://', 'https://') - LNbits.api - .request('GET', `${lnurl.replace('lnurlw://', 'https://')}`) - .then(res => { - self.makeWithdraw(res.data.payLink, readerAbortController) - }) - } else { - self.payInvoice(lnurl, readerAbortController) - } - - this.$q.notify({ - type: 'positive', - message: 'NFC tag read successfully.' - }) - } - }) - } catch (error) { - this.nfcTagReading = false - this.$q.notify({ - type: 'negative', - message: error - ? error.toString() - : 'An unexpected error has occurred.' - }) - } - }, - makeWithdraw(payLink, readerAbortController) { - if (!payLink) { - this.$q.notify({ - type: 'negative', - message: 'LNURL not found in NFC tag.' - }) - return - } - LNbits.api - .request( - 'GET', - `/tpos/api/v1/atm/withdraw/${this.atmToken}/${this.sat}/pay?payLink=${payLink}` - ) - .then(res => { - if (!res.data.success) { - this.$q.notify({ - type: 'negative', - message: res.data.detail - }) - } else { - this.stack = [] - this.total = 0.0 - this.$q.notify({ - type: 'positive', - message: 'Topup successful!' - }) - } - readerAbortController.abort() - }) - .catch(e => { - console.error(e) - readerAbortController.abort() - }) - }, - payInvoice: function (lnurl, readerAbortController) { - const self = this - - return axios - .post( - '/tpos/api/v1/tposs/' + - self.tposId + - '/invoices/' + - self.invoiceDialog.data.payment_request + - '/pay', - { - lnurl: lnurl - } - ) - .then(response => { - if (!response.data.success) { - this.$q.notify({ - type: 'negative', - message: response.data.detail - }) - } - - readerAbortController.abort() - }) - }, - getRates() { - if (this.currency == 'sats') { - this.exchangeRate = 1 - } else { - LNbits.api - .request('GET', `/tpos/api/v1/rate/${this.currency}`) - .then(response => { - this.exchangeRate = response.data.rate - }) - .catch(e => console.error(e)) - } - }, - getLastPayments() { - return axios - .get(`/tpos/api/v1/tposs/${this.tposId}/invoices`) - .then(res => { - if (res.data && res.data.length) { - let last = [...res.data] - this.lastPaymentsDialog.data = last.map(obj => { - obj.dateFrom = moment(obj.time * 1000).fromNow() - return obj - }) - } - }) - .catch(e => console.error(e)) - }, - showLastPayments() { - this.getLastPayments() - this.lastPaymentsDialog.show = true - }, - toggleFullscreen() { - if (document.fullscreenElement) return document.exitFullscreen() - - const elem = document.documentElement - elem - .requestFullscreen({navigationUI: 'show'}) - .then(() => { - document.addEventListener('fullscreenchange', () => { - this.isFullScreen = document.fullscreenElement - }) - }) - .catch(err => console.error(err)) - }, - handleColorScheme(val) { - this.$q.localStorage.set('lnbits.tpos.color', val) - }, - extractCategories(items) { - let categories = new Set() - items - .map(item => { - item.categories && - item.categories.forEach(category => categories.add(category)) - }) - .filter(Boolean) - - if (categories.size) { - categories = ['All', ...categories] - } - return Array.from(categories) - }, - handleCategoryBtn(category) { - if (this.categoryFilter == category) { - this.categoryFilter = '' - } else { - this.categoryFilter = category == 'All' ? '' : category - } - }, - formatAmount: function (amount, currency) { - if (currency == 'sats') { - return LNbits.utils.formatSat(amount) + ' sat' - } else { - return LNbits.utils.formatCurrency( - Number(amount).toFixed(2), - currency - ) - } - } - }, - created: function () { - var getRates = this.getRates - getRates() - this.pinDisabled = JSON.parse('{{ tpos.withdrawpindisabled | tojson }}') - this.taxInclusive = JSON.parse('{{ tpos.tax_inclusive | tojson }}') - - this.tip_options = - '{{ tpos.tip_options | tojson }}' == 'null' + - if ('{{ tpos.tip_wallet }}') { - this.tip_options.push('Round') - } - - this.items = JSON.parse(`{{ tpos.items | safe }}`) - 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) - } - - window.addEventListener('keyup', event => { - // do nothing if the event was already processed - if (event.defaultPrevented) return - - // active only in the the PoS mode, not in the Cart mode or ATM pin - if (!this.showPoS || this.atmBox) return - - // prevent weird behaviour when setting round tip - if (this.tipDialog.show) return - - const {key} = event - if (key >= '0' && key <= '9') { - // buttons 0 ... 9 - this.stack.push(Number(key)) - } else - switch (key) { - case 'Backspace': // button ⬅ - this.stack.pop() - break - case 'Enter': // button OK - this.submitForm() - break - case 'c': // button C - case 'C': - case 'Escape': - if (this.total > 0.0) { - this.cancelAddAmount() - } else { - this.stack = [] - } - break - case '+': // button + - this.addAmount() - break - default: - // return if we didn't handle anything - return - } - - // cancel the default action to avoid it being handled twice - event.preventDefault() - }) + - setInterval(function () { - getRates() - }, 120000) - } - }) - {% endblock %} {% block scripts %} - + - { - name: 'id', - align: 'left', - label: 'ID', - field: 'id' - }, - { - name: 'title', - align: 'left', - label: 'Title', - field: 'title' - }, - { - name: 'price', - align: 'left', - label: 'Price', - field: 'price' - }, - { - name: 'disabled', - align: 'left', - label: 'Disabled', - field: 'disabled' - } - ], - pagination: { - rowsPerPage: 10 - } - }, - monochrome: this.$q.localStorage.getItem('lnbits.tpos.color') || false, - showPoS: true, - cartDrawer: this.$q.screen.width > 1200, - searchTerm: '', - categoryFilter: '', - cart: new Map(), - denomIsSats: '{{ tpos.currency }}' == 'sats' - } - }, - computed: { - amount: function () { - if (!this.stack.length) return 0.0 - return ( - this.stack.reduce((acc, dig) => acc * 10 + dig, 0) * - (this.currency == 'sats' ? 1 : 0.01) - ) - }, - amountFormatted: function () { - return this.formatAmount(this.amount, this.currency) - }, - totalFormatted() { - return this.formatAmount(this.total, this.currency) - }, - amountWithTipFormatted: function () { - return this.formatAmount(this.amount + this.tipAmount, this.currency) - }, - sat: function () { - if (!this.exchangeRate) return 0 - return Math.ceil(this.amount * this.exchangeRate) - }, - totalSat: function () { - if (!this.exchangeRate) return 0 - return Math.ceil(this.total * this.exchangeRate) - }, - tipAmountSat: function () { - if (!this.exchangeRate) return 0 - return Math.ceil(this.tipAmount * this.exchangeRate) - }, - tipAmountFormatted: function () { - return LNbits.utils.formatSat(this.tipAmountSat) - }, - fsat: function () { - return LNbits.utils.formatSat(this.sat) - }, - totalfsat: function () { - return LNbits.utils.formatSat(this.totalSat) - }, - roundToSugestion() { - switch (true) { - case this.amount > 50: - toNext = 10 - break - case this.amount > 6: - toNext = 5 - break - case this.amount > 2.5: - toNext = 1 - break - default: - toNext = 0.5 - break - } - - return Math.ceil(this.amount / toNext) * toNext - }, - fullScreenIcon() { - return this.isFullScreen ? 'fullscreen_exit' : 'fullscreen' - }, - filteredItems() { - // filter out disabled items - let items = this.items.filter(item => !item.disabled) - // if searchTerm entered, filter out items that don't match - if (this.searchTerm) { - items = items.filter(item => { - return item.title - .toLowerCase() - .includes(this.searchTerm.toLowerCase()) - }) - } - // if categoryFilter entered, filter out items that don't match - if (this.categoryFilter) { - items = items.filter(item => { - return item.categories - .map(c => c.toLowerCase()) - .includes(this.categoryFilter.toLowerCase()) - }) - } - return items - }, - drawerWidth() { - return this.$q.screen.width < 500 ? 375 : 450 - } - }, - methods: { - addAmount() { - this.total = +(this.total + this.amount).toFixed(2) - this.stack = [] - }, - cancelAddAmount() { - this.total = 0.0 - this.stack = [] - }, - addToCart(item, quantity = 1) { - if (this.cart.has(item.id)) { - this.cart.set(item.id, { - ...this.cart.get(item.id), - quantity: this.cart.get(item.id).quantity + quantity - }) - } else { - this.cart.set(item.id, { - ...item, - quantity: quantity - }) - } - this.total = this.total + this.calculateItemPrice(item, quantity) - // this.total = this.total + item.price * quantity - }, - removeFromCart(item, quantity = 1) { - let item_quantity = this.cart.get(item.id).quantity - if (item_quantity == 1 || item_quantity == quantity) { - this.cart.delete(item.id) - } else { - this.cart.set(item.id, { - ...this.cart.get(item.id), - quantity: this.cart.get(item.id).quantity - quantity - }) - } - // this.total = this.total - item.price * quantity - this.total = this.total - this.calculateItemPrice(item, quantity) - }, - calculateItemPrice(item, qty){ - if(!item.tax || this.taxInclusive) return item.price * qty - - // add tax to price - return item.price * (1 + item.tax * 0.01) * qty - }, - clearCart() { - this.cart.clear() - this.total = 0.0 - }, - atm() { - if (this.atmPremium > 0) { - this.exchangeRate = this.exchangeRate / (1 + this.atmPremium) - } - if (this.withdrawpinopen != 0) { - this.atmPin = this.withdrawpinopen - this.atmSubmit() - return - } - if (this.withdrawamtposs > 0) { - this.atmBox = true - } - }, - atmSubmit() { - self = this - LNbits.api - .request('GET', `/tpos/api/v1/atm/` + this.tposId + `/` + this.atmPin) - .then(function (res) { - self.atmToken = res.data.id - if (res.data.claimed == false) { - self.atmBox = false - self.atmMode = true - } - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - atmGetWithdraw: function () { - self = this - var dialog = this.invoiceDialog - LNbits.api - .request( - 'GET', - `/tpos/api/v1/atm/withdraw/` + this.atmToken + `/` + this.sat - ) - .then(function (res) { - lnurl = res.data.lnurl - dialog.data = {payment_request: lnurl} - dialog.show = true - self.readNfcTag() - dialog.dismissMsg = self.$q.notify({ - timeout: 0, - message: 'Withdraw...' - }) - if (location.protocol !== 'http:') { - self.withdrawUrl = - 'wss://' + - document.domain + - ':' + - location.port + - '/api/v1/ws/' + - self.atmToken - } else { - self.withdrawUrl = - 'ws://' + - document.domain + - ':' + - location.port + - '/api/v1/ws/' + - self.atmToken - } - this.connectionWithdraw = new WebSocket(self.withdrawUrl) - this.connectionWithdraw.onmessage = e => { - if (e.data == 'paid') { - dialog.show = false - self.atmPin = null - self.atmToken = '' - self.complete.show = true - self.atmMode = false - this.connectionWithdraw.close() - } - } - this.getRates() - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - setRounding() { - this.rounding = true - this.tipRounding = this.roundToSugestion - this.$nextTick(() => this.$refs.inputRounding.focus()) - }, - calculatePercent() { - let change = ((this.tipRounding - this.amount) / this.amount) * 100 - if (change < 0) { - this.$q.notify({ - type: 'warning', - message: 'Amount with tip must be greater than initial amount.' - }) - this.tipRounding = this.roundToSugestion - return - } - this.processTipSelection(change) - }, - closeInvoiceDialog: function () { - this.stack = [] - this.tipAmount = 0.0 - var dialog = this.invoiceDialog - setTimeout(function () { - clearInterval(dialog.paymentChecker) - dialog.dismissMsg() - }, 3000) - }, - processTipSelection: function (selectedTipOption) { - this.tipDialog.show = false - - if (!selectedTipOption) { - this.tipAmount = 0.0 - return this.showInvoice() - } - - this.tipAmount = (selectedTipOption / 100) * this.amount - this.showInvoice() - }, - submitForm: function () { - if (this.total != 0.0) { - if (this.currency == 'sats') { - this.stack = Array.from(String(Math.ceil(this.total), Number)) - } else { - this.stack = Array.from(String(Math.ceil(this.total * 100)), Number) - } - } - if (!this.exchangeRate || this.exchangeRate == 0 || this.sat == 0) { - this.$q.notify({ - type: 'negative', - message: - 'Exchange rate not available, or wrong value. Please try again later.' - }) - return - } - - if (this.tip_options && this.tip_options.length) { - this.rounding = false - this.tipRounding = null - this.showTipModal() - } else { - this.showInvoice() - } - }, - showTipModal: function () { - if (!this.atmMode) { - this.tipDialog.show = true - } else { - this.showInvoice() - } - }, - showInvoice: function () { - var self = this - if (self.atmMode) { - this.atmGetWithdraw() - } else { - var dialog = this.invoiceDialog - let params = { - amount: this.sat, - memo: this.amountFormatted, - tipAmount: this.tipAmountSat - } - if (this.cart.size) { - let details = [...this.cart.values()].map(item => { - return { - price: item.price, - formattedPrice: item.formattedPrice, - quantity: item.quantity, - title: item.title, - tax: item.tax - } - }) - - params.details = JSON.stringify({ - currency: this.currency, - exchangeRate: this.exchangeRate, - items: details, - taxIncluded: this.taxInclusive, - }) - } - - axios - .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, { - params: {...params} - }) - .then(function (response) { - dialog.data = response.data - dialog.show = true - self.readNfcTag() - - dialog.dismissMsg = self.$q.notify({ - timeout: 0, - message: 'Waiting for payment...' - }) - - dialog.paymentChecker = setInterval(function () { - axios - .get( - '/tpos/api/v1/tposs/' + - self.tposId + - '/invoices/' + - response.data.payment_hash - ) - .then(function (res) { - if (res.data.paid) { - clearInterval(dialog.paymentChecker) - dialog.dismissMsg() - dialog.show = false - self.clearCart() - - self.complete.show = true - } - }) - }, 3000) - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - } - }, - readNfcTag: function () { - try { - const self = this - - if (typeof NDEFReader == 'undefined') { - console.debug('NFC not supported on this device or browser.') - return - } - - const ndef = new NDEFReader() - - const readerAbortController = new AbortController() - readerAbortController.signal.onabort = event => { - console.debug('All NFC Read operations have been aborted.') - } - - this.nfcTagReading = true - this.$q.notify({ - message: this.atmMode - ? 'Tap your NFC tag to withdraw with LNURLp.' - : 'Tap your NFC tag to pay this invoice with LNURLw.' - }) - - return ndef.scan({signal: readerAbortController.signal}).then(() => { - ndef.onreadingerror = () => { - self.nfcTagReading = false - - this.$q.notify({ - type: 'negative', - message: 'There was an error reading this NFC tag.' - }) - - readerAbortController.abort() - } - - ndef.onreading = ({message}) => { - //Decode NDEF data from tag - const textDecoder = new TextDecoder('utf-8') - - const record = message.records.find(el => { - const payload = textDecoder.decode(el.data) - return payload.toUpperCase().indexOf('LNURL') !== -1 - }) - - const lnurl = textDecoder.decode(record.data) - - //User feedback, show loader icon - self.nfcTagReading = false - if (self.atmMode) { - const URL = lnurl.replace('lnurlp://', 'https://') - LNbits.api - .request('GET', `${lnurl.replace('lnurlw://', 'https://')}`) - .then(res => { - self.makeWithdraw(res.data.payLink, readerAbortController) - }) - } else { - self.payInvoice(lnurl, readerAbortController) - } - - this.$q.notify({ - type: 'positive', - message: 'NFC tag read successfully.' - }) - } - }) - } catch (error) { - this.nfcTagReading = false - this.$q.notify({ - type: 'negative', - message: error - ? error.toString() - : 'An unexpected error has occurred.' - }) - } - }, - makeWithdraw(payLink, readerAbortController) { - if (!payLink) { - this.$q.notify({ - type: 'negative', - message: 'LNURL not found in NFC tag.' - }) - return - } - LNbits.api - .request( - 'GET', - `/tpos/api/v1/atm/withdraw/${this.atmToken}/${this.sat}/pay?payLink=${payLink}` - ) - .then(res => { - if (!res.data.success) { - this.$q.notify({ - type: 'negative', - message: res.data.detail - }) - } else { - this.stack = [] - this.total = 0.0 - this.$q.notify({ - type: 'positive', - message: 'Topup successful!' - }) - } - readerAbortController.abort() - }) - .catch(e => { - console.error(e) - readerAbortController.abort() - }) - }, - payInvoice: function (lnurl, readerAbortController) { - const self = this - - return axios - .post( - '/tpos/api/v1/tposs/' + - self.tposId + - '/invoices/' + - self.invoiceDialog.data.payment_request + - '/pay', - { - lnurl: lnurl - } - ) - .then(response => { - if (!response.data.success) { - this.$q.notify({ - type: 'negative', - message: response.data.detail - }) - } - - readerAbortController.abort() - }) - }, - getRates() { - if (this.currency == 'sats') { - this.exchangeRate = 1 - } else { - LNbits.api - .request('GET', `/tpos/api/v1/rate/${this.currency}`) - .then(response => { - this.exchangeRate = response.data.rate - }) - .catch(e => console.error(e)) - } - }, - getLastPayments() { - return axios - .get(`/tpos/api/v1/tposs/${this.tposId}/invoices`) - .then(res => { - if (res.data && res.data.length) { - let last = [...res.data] - this.lastPaymentsDialog.data = last.map(obj => { - obj.dateFrom = moment(obj.time * 1000).fromNow() - return obj - }) - } - }) - .catch(e => console.error(e)) - }, - showLastPayments() { - this.getLastPayments() - this.lastPaymentsDialog.show = true - }, - toggleFullscreen() { - if (document.fullscreenElement) return document.exitFullscreen() - - const elem = document.documentElement - elem - .requestFullscreen({navigationUI: 'show'}) - .then(() => { - document.addEventListener('fullscreenchange', () => { - this.isFullScreen = document.fullscreenElement - }) - }) - .catch(err => console.error(err)) - }, - handleColorScheme(val) { - this.$q.localStorage.set('lnbits.tpos.color', val) - }, - extractCategories(items) { - let categories = new Set() - items - .map(item => { - item.categories && - item.categories.forEach(category => categories.add(category)) - }) - .filter(Boolean) - - if (categories.size) { - categories = ['All', ...categories] - } - return Array.from(categories) - }, - handleCategoryBtn(category) { - if (this.categoryFilter == category) { - this.categoryFilter = '' - } else { - this.categoryFilter = category == 'All' ? '' : category - } - }, - formatAmount: function (amount, currency) { - if (currency == 'sats') { - return LNbits.utils.formatSat(amount) + ' sat' - } else { - return LNbits.utils.formatCurrency( - Number(amount).toFixed(2), - currency - ) - } - } - }, - created: function () { - var getRates = this.getRates - getRates() - this.pinDisabled = JSON.parse('{{ tpos.withdrawpindisabled | tojson }}') - this.taxInclusive = JSON.parse('{{ tpos.tax_inclusive | tojson }}') - - this.tip_options = - '{{ tpos.tip_options | tojson }}' == 'null' + - if ('{{ tpos.tip_wallet }}') { - this.tip_options.push('Round') - } - - this.items = JSON.parse(`{{ tpos.items | safe }}`) - 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) - } - - window.addEventListener('keyup', event => { - // do nothing if the event was already processed - if (event.defaultPrevented) return - - // active only in the the PoS mode, not in the Cart mode or ATM pin - if (!this.showPoS || this.atmBox) return - - // prevent weird behaviour when setting round tip - if (this.tipDialog.show) return - - const {key} = event - if (key >= '0' && key <= '9') { - // buttons 0 ... 9 - this.stack.push(Number(key)) - } else - switch (key) { - case 'Backspace': // button ⬅ - this.stack.pop() - break - case 'Enter': // button OK - this.submitForm() - break - case 'c': // button C - case 'C': - case 'Escape': - if (this.total > 0.0) { - this.cancelAddAmount() - } else { - this.stack = [] - } - break - case '+': // button + - this.addAmount() - break - default: - // return if we didn't handle anything - return - } - - // cancel the default action to avoid it being handled twice - event.preventDefault() - }) + - setInterval(function () { - getRates() - }, 120000) - } - }) -