From fb9e661a2eaebd0015eac9ac82c3158ff2a5ea70 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Wed, 18 Mar 2026 19:05:39 +0000 Subject: [PATCH 1/7] fix: easier wrapper link flow --- static/js/index.js | 72 ++++++++++++++++++++++++++ templates/tpos/index.html | 61 +++++++++++++++------- views_api.py | 105 +++++++++++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 21 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 4c042ab..8e09d67 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -23,6 +23,10 @@ const mapTpos = obj => { : [] obj.only_show_sats_on_bitcoin = obj.only_show_sats_on_bitcoin ?? true obj.allow_cash_settlement = Boolean(obj.allow_cash_settlement) + obj.useWrapper = false + obj.posLocation = '' + obj.auth = '' + obj.loadingWrapperToken = false obj.itemsMap = new Map() obj.items.forEach((item, idx) => { let id = `${obj.id}:${idx + 1}` @@ -634,6 +638,67 @@ window.app = Vue.createApp({ this.fileDataDialog.show = false } }, + buildTposShareUrl(tpos) { + if (!tpos) return '' + + if (!tpos.useWrapper) { + return tpos.shareUrl || '' + } + + if (tpos.loadingWrapperToken && !tpos.auth) { + return '' + } + + const url = new URL( + `https://quickskink4974.lnpro.xyz/tpos/${encodeURIComponent(tpos.id)}` + ) + url.searchParams.set('wrapper', 'true') + + if (tpos.posLocation) { + url.searchParams.set('pos', tpos.posLocation) + } + if (tpos.auth) { + url.searchParams.set('auth', tpos.auth) + } + + return url.toString() + }, + async generateWrapperToken(tpos) { + if (!tpos?.useWrapper || tpos.loadingWrapperToken) { + return + } + + const wallet = _.findWhere(this.g.user.wallets, { + id: tpos.wallet + }) + if (!wallet) { + tpos.useWrapper = false + Quasar.Notify.create({ + type: 'warning', + message: 'Unable to find the wallet for this TPoS.' + }) + return + } + + tpos.loadingWrapperToken = true + try { + const {data} = await LNbits.api.request( + 'POST', + `/tpos/api/v1/tposs/${tpos.id}/wrapper-token`, + wallet.adminkey, + {} + ) + tpos.auth = data.auth + Quasar.Notify.create({ + type: 'positive', + message: 'ACL token generated.' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + tpos.loadingWrapperToken = false + } + }, openUrlDialog(id) { if (this.tposs.stripe_card_payments) { this.urlDialog.data = _.findWhere(this.tposs, { @@ -643,6 +708,13 @@ window.app = Vue.createApp({ } else { this.urlDialog.data = _.findWhere(this.tposs, {id}) } + this.urlDialog.data = { + ...this.urlDialog.data, + useWrapper: false, + posLocation: '', + auth: '', + loadingWrapperToken: false + } this.urlDialog.show = true }, formatAmount(amount, currency) { diff --git a/templates/tpos/index.html b/templates/tpos/index.html index 09a76bd..acd5141 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -353,10 +353,10 @@
{{SITE_TITLE}} TPoS extension
> apk (Android only).
* Create a location for a terminal in Stripe.
- * Create an ACL token in LNbits with permissions for "fiat" - Access Control List.
- * Click the QR for the PoS record and add a Stripe terminal location ID - and ACL token.
+ * Click the QR for the PoS record, enable the wrapper option, and add a + Stripe terminal location ID.
+ * LNbits will generate a 2-year ACL token with /fiat permissions and + add it to the wrapper URL automatically.
* Scan the QR in the TPoS Wrapper app.
@@ -760,40 +760,62 @@
{{SITE_TITLE}} TPoS extension
- +

-
- +

+ +
+
+
+
+ +
+
+
+
-
+
- Create a new location and copy the ID here - https://dashboard.stripe.com/terminal + If accepting Stripe payments, visit + https://dashboard.stripe.com/terminal and grab a new location ID
-
+
- +
- - Go to /account and set a new auth token with /fiat read and write - permissions. - + Press if accepting Stripe payments.
@@ -801,7 +823,8 @@
{{SITE_TITLE}} TPoS extension
Copy URL Close diff --git a/views_api.py b/views_api.py index cc3faf2..dd86108 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,9 @@ import json +from datetime import datetime, timezone from http import HTTPStatus +from time import time from typing import Any +from uuid import uuid4 import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -12,14 +15,25 @@ get_wallet, ) from lnbits.core.crud.payments import update_payment_checking_id -from lnbits.core.crud.users import update_account +from lnbits.core.crud.users import ( + get_user_access_control_lists, + update_account, + update_user_access_control_list, +) from lnbits.core.models import CreateInvoice, Payment, WalletTypeInfo -from lnbits.core.models.users import UserLabel +from lnbits.core.models.misc import SimpleItem +from lnbits.core.models.users import ( + AccessControlList, + AccessTokenPayload, + EndpointAccess, + UserLabel, +) from lnbits.core.services import create_payment_request, websocket_updater from lnbits.decorators import ( require_admin_key, require_invoice_key, ) +from lnbits.helpers import create_access_token, get_api_routes from lnbits.tasks import internal_invoice_queue_put from lnurl import LnurlPayResponse from lnurl import decode as decode_lnurl @@ -55,6 +69,16 @@ tpos_api_router = APIRouter() +def _two_year_token_expiry_minutes() -> int: + now = datetime.now(timezone.utc) + try: + expires_at = now.replace(year=now.year + 2) + except ValueError: + # Handle February 29 by falling back to February 28 two years later. + expires_at = now.replace(year=now.year + 2, month=2, day=28) + return max(1, int((expires_at - now).total_seconds() // 60)) + + @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( all_wallets: bool = Query(False), @@ -179,6 +203,83 @@ async def api_tpos_delete( return "", HTTPStatus.NO_CONTENT +@tpos_api_router.post("/api/v1/tposs/{tpos_id}/wrapper-token") +async def api_tpos_create_wrapper_token( + tpos_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + tpos = await get_tpos(tpos_id) + + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + if tpos.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") + + account = await get_account(wallet.wallet.user) + if not account or not account.username: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="A username is required to create a wrapper ACL token.", + ) + + user_acls = await get_user_access_control_lists(account.id) + acl_name = "TPoS Wrapper Fiat" + acl = next( + ( + existing_acl + for existing_acl in user_acls.access_control_list + if existing_acl.name == acl_name + ), + None, + ) + + api_routes = get_api_routes(request.app.router.routes) + fiat_endpoints = [] + for path, name in api_routes.items(): + is_fiat_endpoint = path.startswith("/api/v1/fiat") + fiat_endpoints.append( + EndpointAccess( + path=path, + name=name, + read=is_fiat_endpoint, + write=is_fiat_endpoint, + ) + ) + fiat_endpoints.sort(key=lambda e: e.name.lower()) + + if acl: + acl.endpoints = fiat_endpoints + else: + acl = AccessControlList( + id=uuid4().hex, + name=acl_name, + endpoints=fiat_endpoints, + token_id_list=[], + ) + user_acls.access_control_list.append(acl) + user_acls.access_control_list.sort(key=lambda existing_acl: existing_acl.name.lower()) + + token_expire_minutes = _two_year_token_expiry_minutes() + api_token_id = uuid4().hex + payload = AccessTokenPayload( + sub=account.username, api_token_id=api_token_id, auth_time=int(time()) + ) + api_token = create_access_token( + data=payload.dict(), token_expire_minutes=token_expire_minutes + ) + + acl.token_id_list.append( + SimpleItem(id=api_token_id, name=f"TPoS Wrapper {tpos_id}") + ) + await update_user_access_control_list(user_acls) + + return {"auth": api_token, "expiration_time_minutes": token_expire_minutes} + + @tpos_api_router.post( "/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED ) From e8c4c4b8f0c2fdfd82ee13e16a3022a7b8c12c1d Mon Sep 17 00:00:00 2001 From: arcbtc Date: Wed, 18 Mar 2026 19:13:55 +0000 Subject: [PATCH 2/7] make --- templates/tpos/index.html | 4 ++-- views_api.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/templates/tpos/index.html b/templates/tpos/index.html index acd5141..9db2b82 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -355,8 +355,8 @@
{{SITE_TITLE}} TPoS extension
* Create a location for a terminal in Stripe.
* Click the QR for the PoS record, enable the wrapper option, and add a Stripe terminal location ID.
- * LNbits will generate a 2-year ACL token with /fiat permissions and - add it to the wrapper URL automatically.
+ * LNbits will generate a 2-year ACL token with /fiat permissions and add + it to the wrapper URL automatically.
* Scan the QR in the TPoS Wrapper app.
diff --git a/views_api.py b/views_api.py index dd86108..21cec49 100644 --- a/views_api.py +++ b/views_api.py @@ -261,7 +261,9 @@ async def api_tpos_create_wrapper_token( token_id_list=[], ) user_acls.access_control_list.append(acl) - user_acls.access_control_list.sort(key=lambda existing_acl: existing_acl.name.lower()) + user_acls.access_control_list.sort( + key=lambda existing_acl: existing_acl.name.lower() + ) token_expire_minutes = _two_year_token_expiry_minutes() api_token_id = uuid4().hex From a1bf3fe6e590265d4842f892deed5c7cf61ec466 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Wed, 18 Mar 2026 21:09:00 +0000 Subject: [PATCH 3/7] fix for silent printing in the wrapper --- models.py | 146 +++++++++++++++++++++++++++++++++++++++ static/js/tpos.js | 56 +++++++++++++++ templates/tpos/tpos.html | 52 ++++++-------- views_api.py | 90 +++++++++++++++++++++--- 4 files changed, 305 insertions(+), 39 deletions(-) diff --git a/models.py b/models.py index 86c3197..ff13df5 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,8 @@ from __future__ import annotations +from datetime import datetime from time import time +from typing import Any, Literal from fastapi import Query from pydantic import BaseModel, Field, validator @@ -166,4 +168,148 @@ class TapToPay(BaseModel): paid: bool = False +class PrintReceiptRequest(BaseModel): + receipt_type: Literal["receipt", "order_receipt"] = "receipt" + + +class ReceiptItemData(BaseModel): + title: str = "" + note: str | None = None + quantity: int = 0 + price: float = 0.0 + + +class ReceiptDetailsData(BaseModel): + currency: str = "sats" + exchangeRate: float = 1.0 + taxValue: float = 0.0 + taxIncluded: bool = False + items: list[ReceiptItemData] = Field(default_factory=list) + + +class ReceiptExtraData(BaseModel): + amount: int = 0 + paid_in_fiat: bool = False + fiat_method: str | None = None + fiat_payment_request: str | None = None + details: ReceiptDetailsData = Field(default_factory=ReceiptDetailsData) + + +class ReceiptData(BaseModel): + paid: bool = False + extra: ReceiptExtraData = Field(default_factory=ReceiptExtraData) + created_at: Any = None + business_name: str | None = None + business_address: str | None = None + business_vat_id: str | None = None + only_show_sats_on_bitcoin: bool = True + + def paid_in_fiat(self) -> bool: + return bool( + self.extra.paid_in_fiat + or self.extra.fiat_method + or self.extra.fiat_payment_request + ) + + def show_bitcoin_details(self) -> bool: + return (not self.only_show_sats_on_bitcoin) or (not self.paid_in_fiat()) + + def subtotal(self) -> float: + if self.extra.details.items: + return sum( + item.price * item.quantity for item in self.extra.details.items + ) + rate = self.extra.details.exchangeRate or 1.0 + return self.extra.amount / rate + + def total(self) -> float: + if not self.extra.details.items: + rate = self.extra.details.exchangeRate or 1.0 + return self.extra.amount / rate + if self.extra.details.taxIncluded: + return self.subtotal() + return self.subtotal() + self.extra.details.taxValue + + def format_money(self, amount: float) -> str: + return f"{amount:.2f} {self.extra.details.currency.upper()}" + + def formatted_created_at(self) -> str | None: + if not self.created_at: + return None + if isinstance(self.created_at, datetime): + return self.created_at.strftime("%Y-%m-%d %H:%M") + value = str(self.created_at).strip() + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed.strftime("%Y-%m-%d %H:%M") + except ValueError: + return value + + def render_text( + self, receipt_type: Literal["receipt", "order_receipt"] = "receipt" + ) -> str: + lines: list[str] = [] + lines.append("ORDER" if receipt_type == "order_receipt" else "RECEIPT") + formatted_created_at = self.formatted_created_at() + if formatted_created_at: + lines.append(formatted_created_at) + if self.show_bitcoin_details() and receipt_type != "order_receipt": + lines.append( + f"Rate (sat/{self.extra.details.currency}): " + f"{self.extra.details.exchangeRate:.2f}" + ) + lines.append("") + + for item in self.extra.details.items: + if item.title.strip(): + lines.append(item.title.strip()) + if receipt_type == "order_receipt": + lines.append(f"Qty: {item.quantity}") + else: + lines.append(f"{item.quantity} x {self.format_money(item.price)}") + if item.note and item.note.strip(): + lines.append(item.note.strip()) + lines.append("") + + if receipt_type != "order_receipt": + lines.append(f"Subtotal: {self.format_money(self.subtotal())}") + lines.append(f"Tax: {self.format_money(self.extra.details.taxValue)}") + lines.append(f"Total: {self.format_money(self.total())}") + if self.show_bitcoin_details(): + lines.append(f"Total (sats): {self.extra.amount}") + lines.append("") + lines.append("Thank you for your purchase!") + + if receipt_type != "order_receipt": + if self.business_name: + lines.append(self.business_name) + if self.business_address: + lines.extend( + line for line in self.business_address.splitlines() if line.strip() + ) + if self.business_vat_id: + lines.append(f"VAT: {self.business_vat_id}") + + while lines and not lines[-1].strip(): + lines.pop() + return "\n".join(lines) + + def to_api_dict(self) -> dict[str, Any]: + data = self.dict() + data["print_text"] = self.render_text("receipt") + data["order_print_text"] = self.render_text("order_receipt") + return data + + +class ReceiptPrint(BaseModel): + type: str = "receipt_print" + tpos_id: str | None = None + payment_hash: str | None = None + receipt_type: Literal["receipt", "order_receipt"] = "receipt" + print_text: str = "" + receipt: dict[str, Any] = Field(default_factory=dict) + + CreateTposInvoice.update_forward_refs(InventorySale=InventorySale) diff --git a/static/js/tpos.js b/static/js/tpos.js index b5898ff..fe5662f 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -127,7 +127,9 @@ window.app = Vue.createApp({ addedAmount: 0, enablePrint: false, enableRemote: false, + wrapperMode: false, receiptData: null, + printText: '', orderReceipt: false, printDialog: { show: false, @@ -1330,14 +1332,61 @@ window.app = Vue.createApp({ this.printDialog.show = false this.printDialog.paymentHash = null this.receiptData = null + this.printText = '' + }, + escapePrintHtml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + }, + renderPrintHtml(printText, isOrderReceipt = false) { + const blocks = String(printText || '') + .split(/\n\s*\n/) + .map(block => block.trim()) + .filter(Boolean) + + if (!blocks.length) return '' + + return blocks + .map((block, index) => { + const className = + index === 0 + ? 'receipt-block receipt-block-header' + : !isOrderReceipt && index === blocks.length - 1 + ? 'receipt-block receipt-block-footer' + : 'receipt-block' + return `
${this.escapePrintHtml(block)}
` + }) + .join('') + }, + async sendWrapperPrint(paymentHash, receiptType) { + await LNbits.api.request( + 'POST', + `/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}/print`, + null, + { + receipt_type: receiptType + } + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Print request sent to wrapper.' + }) + this.closePrintDialog() }, async printReceipt(paymentHash) { try { + if (this.wrapperMode) { + await this.sendWrapperPrint(paymentHash, 'receipt') + return + } const {data} = await LNbits.api.request( 'GET', `/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}?extra=true` ) this.receiptData = data + this.printText = data.print_text || '' this.orderReceipt = false console.log('Printing receipt for payment hash:', paymentHash) @@ -1353,11 +1402,16 @@ window.app = Vue.createApp({ }, async printOrderReceipt(paymentHash) { try { + if (this.wrapperMode) { + await this.sendWrapperPrint(paymentHash, 'order_receipt') + return + } const {data} = await LNbits.api.request( 'GET', `/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}?extra=true` ) this.receiptData = data + this.printText = data.order_print_text || '' this.orderReceipt = true console.log('Printing order receipt for payment hash:', paymentHash) @@ -1423,6 +1477,8 @@ window.app = Vue.createApp({ this.tposLNaddressCut = tpos.lnaddress_cut this.enablePrint = tpos.enable_receipt_print this.enableRemote = Boolean(tpos.enable_remote) + this.wrapperMode = + new URL(window.location.href).searchParams.get('wrapper') === 'true' this.fiatProvider = tpos.fiat_provider this.allowCashSettlement = Boolean(tpos.allow_cash_settlement) diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html index a17480b..6f3cfe4 100644 --- a/templates/tpos/tpos.html +++ b/templates/tpos/tpos.html @@ -104,17 +104,11 @@
{% endblock %} {% block styles %} @@ -176,7 +170,8 @@
text-align: left; } @media screen { - .receipt { + .receipt, + .receipt-text { display: none; } } @@ -195,30 +190,27 @@
body > * { display: none !important; } - .receipt { + .receipt, + .receipt-text { display: block !important; margin: 0mm !important; - padding-top: 2cm !important; - padding-bottom: 2cm !important; + padding: 8mm 5mm !important; } - .receipt p { - margin: 0 !important; + .receipt-text { + font-family: monospace !important; + font-size: 16px !important; + line-height: 1.35 !important; + letter-spacing: 0.01em; } - .receipt .q-table__middle { - overflow: visible !important; + .receipt-block { + white-space: pre-wrap !important; + text-align: left; + margin: 0 0 1.1em 0; } - .receipt .q-table td, - .receipt .q-table th { - white-space: normal !important; - word-break: break-word; - } - .order-receipt .q-table th span, - .order-receipt .q-table td div, - .order-receipt .q-table td span, - .order-receipt p { - font-size: 1.5em !important; - line-height: 1.2 !important; + .receipt-block-header, + .receipt-block-footer { + text-align: center; } } diff --git a/views_api.py b/views_api.py index 21cec49..7d09917 100644 --- a/views_api.py +++ b/views_api.py @@ -57,6 +57,12 @@ CreateUpdateItemData, InventorySale, PayLnurlWData, + PrintReceiptRequest, + ReceiptData, + ReceiptDetailsData, + ReceiptExtraData, + ReceiptItemData, + ReceiptPrint, TapToPay, Tpos, ) @@ -79,6 +85,44 @@ def _two_year_token_expiry_minutes() -> int: return max(1, int((expires_at - now).total_seconds() // 60)) +def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData: + extra = payment.extra or {} + details = extra.get("details") or {} + items = details.get("items") or [] + + receipt_items = [ + ReceiptItemData( + title=str(item.get("title") or ""), + note=(str(item.get("note")) if item.get("note") is not None else None), + quantity=int(item.get("quantity") or 0), + price=float(item.get("price") or 0.0), + ) + for item in items + ] + + return ReceiptData( + paid=payment.success, + extra=ReceiptExtraData( + amount=int(extra.get("amount") or 0), + paid_in_fiat=bool(extra.get("paid_in_fiat")), + fiat_method=extra.get("fiat_method"), + fiat_payment_request=extra.get("fiat_payment_request"), + details=ReceiptDetailsData( + currency=str(details.get("currency") or "sats"), + exchangeRate=float(details.get("exchangeRate") or 1.0), + taxValue=float(details.get("taxValue") or 0.0), + taxIncluded=bool(details.get("taxIncluded")), + items=receipt_items, + ), + ), + created_at=payment.created_at, + business_name=tpos.business_name, + business_address=tpos.business_address, + business_vat_id=tpos.business_vat_id, + only_show_sats_on_bitcoin=tpos.only_show_sats_on_bitcoin, + ) + + @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( all_wallets: bool = Query(False), @@ -552,18 +596,46 @@ async def api_tpos_check_invoice( ) if extra: - return { - "paid": payment.success, - "extra": payment.extra, - "created_at": payment.created_at, - "business_name": tpos.business_name, - "business_address": tpos.business_address, - "business_vat_id": tpos.business_vat_id, - "only_show_sats_on_bitcoin": tpos.only_show_sats_on_bitcoin, - } + return _build_receipt_data(tpos, payment).to_api_dict() return {"paid": payment.success} +@tpos_api_router.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/print", + status_code=HTTPStatus.OK, +) +async def api_tpos_print_invoice( + data: PrintReceiptRequest, tpos_id: str, payment_hash: str +): + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + payment = await get_standalone_payment(payment_hash, incoming=True) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + if payment.extra.get("tag") != "tpos" or payment.extra.get("tpos_id") != tpos_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." + ) + + receipt_type = data.receipt_type if data.receipt_type == "order_receipt" else "receipt" + receipt = _build_receipt_data(tpos, payment) + payload = ReceiptPrint( + tpos_id=tpos_id, + payment_hash=payment_hash, + receipt_type=receipt_type, + print_text=receipt.render_text(receipt_type), + receipt=receipt.dict(), + ) + await websocket_updater(tpos_id, json.dumps(payload.dict())) + return {"success": True} + + @tpos_api_router.post( "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/cash/validate", status_code=HTTPStatus.OK, From 8927512d57c2d48d886c048d7721090b0571bbff Mon Sep 17 00:00:00 2001 From: arcbtc Date: Wed, 18 Mar 2026 21:12:27 +0000 Subject: [PATCH 4/7] make --- models.py | 26 ++++++++++++++------------ views_api.py | 14 ++++++++------ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/models.py b/models.py index ff13df5..cc60def 100644 --- a/models.py +++ b/models.py @@ -181,9 +181,9 @@ class ReceiptItemData(BaseModel): class ReceiptDetailsData(BaseModel): currency: str = "sats" - exchangeRate: float = 1.0 - taxValue: float = 0.0 - taxIncluded: bool = False + exchange_rate: float = 1.0 + tax_value: float = 0.0 + tax_included: bool = False items: list[ReceiptItemData] = Field(default_factory=list) @@ -216,19 +216,17 @@ def show_bitcoin_details(self) -> bool: def subtotal(self) -> float: if self.extra.details.items: - return sum( - item.price * item.quantity for item in self.extra.details.items - ) - rate = self.extra.details.exchangeRate or 1.0 + return sum(item.price * item.quantity for item in self.extra.details.items) + rate = self.extra.details.exchange_rate or 1.0 return self.extra.amount / rate def total(self) -> float: if not self.extra.details.items: - rate = self.extra.details.exchangeRate or 1.0 + rate = self.extra.details.exchange_rate or 1.0 return self.extra.amount / rate - if self.extra.details.taxIncluded: + if self.extra.details.tax_included: return self.subtotal() - return self.subtotal() + self.extra.details.taxValue + return self.subtotal() + self.extra.details.tax_value def format_money(self, amount: float) -> str: return f"{amount:.2f} {self.extra.details.currency.upper()}" @@ -258,7 +256,7 @@ def render_text( if self.show_bitcoin_details() and receipt_type != "order_receipt": lines.append( f"Rate (sat/{self.extra.details.currency}): " - f"{self.extra.details.exchangeRate:.2f}" + f"{self.extra.details.exchange_rate:.2f}" ) lines.append("") @@ -275,7 +273,7 @@ def render_text( if receipt_type != "order_receipt": lines.append(f"Subtotal: {self.format_money(self.subtotal())}") - lines.append(f"Tax: {self.format_money(self.extra.details.taxValue)}") + lines.append(f"Tax: {self.format_money(self.extra.details.tax_value)}") lines.append(f"Total: {self.format_money(self.total())}") if self.show_bitcoin_details(): lines.append(f"Total (sats): {self.extra.amount}") @@ -298,6 +296,10 @@ def render_text( def to_api_dict(self) -> dict[str, Any]: data = self.dict() + details = data.get("extra", {}).get("details", {}) + details["exchangeRate"] = details.pop("exchange_rate", 1.0) + details["taxValue"] = details.pop("tax_value", 0.0) + details["taxIncluded"] = details.pop("tax_included", False) data["print_text"] = self.render_text("receipt") data["order_print_text"] = self.render_text("order_receipt") return data diff --git a/views_api.py b/views_api.py index 7d09917..161363c 100644 --- a/views_api.py +++ b/views_api.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from http import HTTPStatus from time import time -from typing import Any +from typing import Any, Literal from uuid import uuid4 import httpx @@ -109,9 +109,9 @@ def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData: fiat_payment_request=extra.get("fiat_payment_request"), details=ReceiptDetailsData( currency=str(details.get("currency") or "sats"), - exchangeRate=float(details.get("exchangeRate") or 1.0), - taxValue=float(details.get("taxValue") or 0.0), - taxIncluded=bool(details.get("taxIncluded")), + exchange_rate=float(details.get("exchangeRate") or 1.0), + tax_value=float(details.get("taxValue") or 0.0), + tax_included=bool(details.get("taxIncluded")), items=receipt_items, ), ), @@ -623,14 +623,16 @@ async def api_tpos_print_invoice( status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." ) - receipt_type = data.receipt_type if data.receipt_type == "order_receipt" else "receipt" + receipt_type: Literal["receipt", "order_receipt"] = ( + "order_receipt" if data.receipt_type == "order_receipt" else "receipt" + ) receipt = _build_receipt_data(tpos, payment) payload = ReceiptPrint( tpos_id=tpos_id, payment_hash=payment_hash, receipt_type=receipt_type, print_text=receipt.render_text(receipt_type), - receipt=receipt.dict(), + receipt=receipt.to_api_dict(), ) await websocket_updater(tpos_id, json.dumps(payload.dict())) return {"success": True} From 4c72f310b30a797547770f79e25bb5c67bb1ed77 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Thu, 19 Mar 2026 00:39:03 +0000 Subject: [PATCH 5/7] bigger order recipt --- models.py | 1 + static/js/tpos.js | 22 ++++++++++++++++------ templates/tpos/tpos.html | 5 ++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/models.py b/models.py index cc60def..2e69f12 100644 --- a/models.py +++ b/models.py @@ -300,6 +300,7 @@ def to_api_dict(self) -> dict[str, Any]: details["exchangeRate"] = details.pop("exchange_rate", 1.0) details["taxValue"] = details.pop("tax_value", 0.0) details["taxIncluded"] = details.pop("tax_included", False) + data["created_at"] = self.formatted_created_at() data["print_text"] = self.render_text("receipt") data["order_print_text"] = self.render_text("order_receipt") return data diff --git a/static/js/tpos.js b/static/js/tpos.js index fe5662f..8572f49 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -1457,9 +1457,22 @@ window.app = Vue.createApp({ 'lnbits.tpos.header', this.headerHidden ? 'hidden' : 'shown' ) + this.applyHeaderVisibility() + }, + applyHeaderVisibility() { + this.headerHidden = + this.$q.localStorage.getItem('lnbits.tpos.header') !== 'shown' if (this.headerElement) { this.headerElement.style.display = this.headerHidden ? 'none' : '' } + }, + bindHeaderVisibilityRefresh() { + const refresh = () => this.applyHeaderVisibility() + window.addEventListener('focus', refresh) + window.addEventListener('pageshow', refresh) + document.addEventListener('visibilitychange', () => { + if (!document.hidden) refresh() + }) } }, async created() { @@ -1509,9 +1522,8 @@ window.app = Vue.createApp({ } }) this.headerElement = document.querySelector('.q-header') - if (this.headerElement) { - this.headerElement.style.display = this.headerHidden ? 'none' : '' - } + this.applyHeaderVisibility() + this.bindHeaderVisibilityRefresh() this.connectRemoteInvoiceWS() }, beforeUnmount() { @@ -1522,9 +1534,7 @@ window.app = Vue.createApp({ if (!this.headerElement) { this.headerElement = document.querySelector('.q-header') } - if (this.headerElement) { - this.headerElement.style.display = this.headerHidden ? 'none' : '' - } + this.applyHeaderVisibility() setInterval(() => { this.getRates() }, 120000) diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html index 6f3cfe4..6504d54 100644 --- a/templates/tpos/tpos.html +++ b/templates/tpos/tpos.html @@ -105,7 +105,7 @@