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,