diff --git a/models.py b/models.py index 86c3197..2e69f12 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,151 @@ 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" + exchange_rate: float = 1.0 + tax_value: float = 0.0 + tax_included: 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.exchange_rate or 1.0 + return self.extra.amount / rate + + def total(self) -> float: + if not self.extra.details.items: + rate = self.extra.details.exchange_rate or 1.0 + return self.extra.amount / rate + if self.extra.details.tax_included: + return self.subtotal() + return self.subtotal() + self.extra.details.tax_value + + 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.exchange_rate:.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.tax_value)}") + 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() + 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["created_at"] = self.formatted_created_at() + 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/index.js b/static/js/index.js index 5a59f62..702ee09 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -47,6 +47,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}` @@ -661,6 +665,68 @@ 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( + `/tpos/${encodeURIComponent(tpos.id)}`, + window.location.origin + ) + 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, { @@ -670,6 +736,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/static/js/tpos.js b/static/js/tpos.js index 17afe33..e64523a 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -164,7 +164,9 @@ window.app = Vue.createApp({ addedAmount: 0, enablePrint: false, enableRemote: false, + wrapperMode: false, receiptData: null, + printText: '', orderReceipt: false, printDialog: { show: false, @@ -301,14 +303,6 @@ window.app = Vue.createApp({ .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() { @@ -1352,6 +1346,12 @@ window.app = Vue.createApp({ this.categoryFilter = category == 'All' ? '' : category } }, + matchesCategoryFilter(item) { + if (!this.categoryFilter) return true + return item.categories + .map(c => c.toLowerCase()) + .includes(this.categoryFilter.toLowerCase()) + }, formatAmount(amount, currency) { if (g.settings.denomination != 'sats') { const scale = getTposCurrencyScale(g.settings.denomination) @@ -1418,14 +1418,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) @@ -1441,11 +1488,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) @@ -1491,9 +1543,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() { @@ -1511,6 +1576,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) @@ -1541,9 +1608,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() { @@ -1554,9 +1620,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/_cart.html b/templates/tpos/_cart.html index 153850d..ecb1c02 100644 --- a/templates/tpos/_cart.html +++ b/templates/tpos/_cart.html @@ -195,7 +195,12 @@
-
+
@@ -217,6 +222,7 @@
style="height: 150px; width: 150px" v-for="item in filteredItems" :key="item.id" + v-show="matchesCategoryFilter(item)" >
@@ -236,7 +242,12 @@
- + {{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/templates/tpos/tpos.html b/templates/tpos/tpos.html index a17480b..6504d54 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,30 @@
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-text--order { + font-size: 24px !important; } - .receipt .q-table td, - .receipt .q-table th { - white-space: normal !important; - word-break: break-word; + .receipt-block { + white-space: pre-wrap !important; + text-align: left; + margin: 0 0 1.1em 0; } - .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 cc3faf2..161363c 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 typing import Any +from time import time +from typing import Any, Literal +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 @@ -43,6 +57,12 @@ CreateUpdateItemData, InventorySale, PayLnurlWData, + PrintReceiptRequest, + ReceiptData, + ReceiptDetailsData, + ReceiptExtraData, + ReceiptItemData, + ReceiptPrint, TapToPay, Tpos, ) @@ -55,6 +75,54 @@ 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)) + + +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"), + 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, + ), + ), + 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), @@ -179,6 +247,85 @@ 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 ) @@ -449,18 +596,48 @@ 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: 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.to_api_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,