diff --git a/crud.py b/crud.py index 00e0d96..c30286a 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import Any from lnbits.db import Database @@ -176,6 +177,21 @@ async def get_latest_tpos_payments(tpos_id: str, limit: int = 5) -> list[TposPay ) +async def get_tpos_payments_between( + tpos_id: str, start: datetime, end: datetime +) -> list[TposPayment]: + return await db.fetchall( + """ + SELECT * FROM tpos.payments + WHERE tpos_id = :tpos_id AND paid = true + AND paid_at >= :start AND paid_at < :end + ORDER BY paid_at ASC + """, + {"tpos_id": tpos_id, "start": start, "end": end}, + TposPayment, + ) + + async def update_tpos_payment(payment: TposPayment) -> TposPayment: await db.update("tpos.payments", payment) return payment diff --git a/migrations.py b/migrations.py index 886c670..ddeee94 100644 --- a/migrations.py +++ b/migrations.py @@ -312,3 +312,17 @@ async def m024_add_assetlinks_cache(db: Database): updated_at INTEGER NOT NULL DEFAULT 0 ); """) + + +async def m025_add_tpos_payment_paid_at(db: Database): + """ + Add a settlement timestamp to tpos payments, distinct from created_at + (invoice creation time). Backfill existing paid rows from updated_at + as a best-effort approximation. + """ + await db.execute(""" + ALTER TABLE tpos.payments ADD paid_at TIMESTAMP NULL; + """) + await db.execute(""" + UPDATE tpos.payments SET paid_at = updated_at WHERE paid = true; + """) diff --git a/models.py b/models.py index fdef6ab..fac9998 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from time import time from typing import Any, Literal @@ -156,6 +156,7 @@ class TposPayment(BaseModel): pending: int = 0 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) + paid_at: datetime | None = None class TposInvoiceResponse(BaseModel): @@ -348,9 +349,91 @@ class ReceiptPrint(BaseModel): type: str = "receipt_print" tpos_id: str | None = None payment_hash: str | None = None - receipt_type: Literal["receipt", "order_receipt"] = "receipt" + receipt_type: Literal["receipt", "order_receipt", "summary"] = "receipt" print_text: str = "" receipt: dict[str, Any] = Field(default_factory=dict) +class TposDailySummary(BaseModel): + tpos_id: str + start: datetime + end: datetime + sales_count: int = 0 + total_sats: int = 0 + totals_by_currency: dict[str, float] = Field(default_factory=dict) + print_text: str = "" + + +SUMMARY_TRANSLATIONS = { + "en": { + "title": "DAILY SUMMARY", + "sales": "Sales", + "total_sats": "Total (sats)", + "total": "Total", + "thanks": "Thank you!", + "vat": "VAT", + }, + "br": { + "title": "RESUMO DIÁRIO", + "sales": "Vendas", + "total_sats": "Total (sats)", + "total": "Total", + "thanks": "Obrigado!", + "vat": "Doc. Fiscal", + }, +} + + +def _normalize_summary_lang(lang: str | None) -> str: + """Map an LNbits UI locale onto a supported summary language. + + The frontend now forwards the LNbits UI language verbatim (e.g. ``br``, + ``en``, ``pt-BR``, ``en-US``), so normalize region suffixes and treat any + Portuguese variant as ``br``; anything else without a translation falls + back to English. + """ + code = (lang or "en").lower().replace("_", "-") + base = code.split("-", 1)[0] + if code == "br" or base in ("br", "pt"): + return "br" + return base if base in SUMMARY_TRANSLATIONS else "en" + + +def render_summary_text( + start: datetime, + end: datetime, + sales_count: int, + total_sats: int, + totals_by_currency: dict[str, float], + business_name: str | None = None, + business_address: str | None = None, + business_vat_id: str | None = None, + lang: str = "en", +) -> str: + t = SUMMARY_TRANSLATIONS[_normalize_summary_lang(lang)] + lines: list[str] = [t["title"]] + if start.date() == (end - timedelta(seconds=1)).date(): + lines.append(start.strftime("%Y-%m-%d")) + else: + lines.append(f"{start.strftime('%Y-%m-%d %H:%M')} - {end.strftime('%Y-%m-%d %H:%M')}") + lines.append("") + lines.append(f"{t['sales']}: {sales_count}") + lines.append(f"{t['total_sats']}: {total_sats}") + for currency, amount in totals_by_currency.items(): + lines.append(f"{t['total']} ({currency.upper()}): {amount:.2f}") + lines.append("") + lines.append(t["thanks"]) + + if business_name: + lines.append(business_name) + if business_address: + lines.extend(line for line in business_address.splitlines() if line.strip()) + if business_vat_id: + lines.append(f"{t['vat']}: {business_vat_id}") + + while lines and not lines[-1].strip(): + lines.pop() + return "\n".join(lines) + + CreateTposInvoice.update_forward_refs(InventorySale=InventorySale) diff --git a/static/js/tpos.js b/static/js/tpos.js index 8da252d..ceda592 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -247,6 +247,22 @@ window.app = Vue.createApp({ } }, computed: { + printSummaryLabel() { + // Mirror the summary's backend languages (en + br) so the button label + // tracks the LNbits UI language, same source used for the receipt text. + const raw = + window.i18n?.global?.locale?.value ?? + window.i18n?.global?.locale ?? + window.g?.locale ?? + 'en' + const code = String(raw).toLowerCase().replace('_', '-') + const base = code.split('-', 1)[0] + const lang = code === 'br' || base === 'br' || base === 'pt' ? 'br' : 'en' + return { + br: 'Imprimir resumo diário', + en: 'Print daily summary' + }[lang] + }, activePaymentAmount() { return this.paymentAmount !== null ? this.paymentAmount : this.amount }, @@ -1582,6 +1598,47 @@ window.app = Vue.createApp({ }) } }, + async printDailySummary() { + const start = moment().startOf('day') + const end = moment(start).add(1, 'day') + // Match the receipt language to the LNbits UI language the operator has + // selected (same source tpos.js uses for number formatting above). + const lang = + window.i18n?.global?.locale?.value ?? + window.i18n?.global?.locale ?? + window.g?.locale ?? + 'en' + const query = `start=${encodeURIComponent( + start.toISOString() + )}&end=${encodeURIComponent(end.toISOString())}&lang=${lang}` + try { + if (this.wrapperMode) { + await LNbits.api.request( + 'POST', + `/tpos/api/v1/tposs/${this.tposId}/summary/print?${query}` + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Print request sent to wrapper.' + }) + return + } + const {data} = await LNbits.api.request( + 'GET', + `/tpos/api/v1/tposs/${this.tposId}/summary?${query}` + ) + this.printText = data.print_text || '' + this.orderReceipt = false + await this.$nextTick() + window.print() + } catch (error) { + console.error('Error fetching daily summary:', error) + Quasar.Notify.create({ + type: 'negative', + message: 'Error fetching daily summary.' + }) + } + }, async addComment() { this.$q .dialog({ diff --git a/tasks.py b/tasks.py index 47ad857..62590d3 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,6 @@ import asyncio import json +from datetime import datetime, timezone from lnbits.core.crud import get_user_active_extensions_ids, get_wallet from lnbits.core.crud.payments import get_standalone_payment, update_payment @@ -60,6 +61,7 @@ async def poll_onchain_payments(): tpos_payment.pending = unconfirmed_balance if settled_balance >= tpos_payment.amount: tpos_payment.paid = True + tpos_payment.paid_at = datetime.now(timezone.utc) tpos_payment.payment_method = "onchain" if changed or tpos_payment.paid: await update_tpos_payment(tpos_payment) @@ -94,6 +96,7 @@ async def on_invoice_paid(payment: Payment) -> None: tpos_payment = await get_tpos_payment_by_hash(payment.payment_hash) if tpos_payment and not tpos_payment.paid: tpos_payment.paid = True + tpos_payment.paid_at = datetime.now(timezone.utc) tpos_payment.payment_method = payment_method await update_tpos_payment(tpos_payment) diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index adaee79..98fc19c 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -176,6 +176,14 @@
+ diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..ed0e9ff --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,90 @@ +from datetime import datetime + +from ..models import render_summary_text + + +def test_render_summary_text_totals(): + text = render_summary_text( + start=datetime(2026, 7, 19, 0, 0), + end=datetime(2026, 7, 20, 0, 0), + sales_count=3, + total_sats=15000, + totals_by_currency={"usd": 12.5, "eur": 4.0}, + business_name="Test Store", + business_address="Rua Exemplo, 123", + business_vat_id="VAT123", + ) + lines = text.splitlines() + + assert lines[0] == "DAILY SUMMARY" + assert "2026-07-19" in lines[1] + assert "Sales: 3" in lines + assert "Total (sats): 15000" in lines + assert "Total (USD): 12.50" in lines + assert "Total (EUR): 4.00" in lines + assert "Test Store" in lines + assert "VAT: VAT123" in lines + + +def test_render_summary_text_no_sales(): + text = render_summary_text( + start=datetime(2026, 7, 19, 0, 0), + end=datetime(2026, 7, 20, 0, 0), + sales_count=0, + total_sats=0, + totals_by_currency={}, + ) + + assert "Sales: 0" in text + assert "Total (sats): 0" in text + + +def test_render_summary_text_pt_br(): + text = render_summary_text( + start=datetime(2026, 7, 19, 0, 0), + end=datetime(2026, 7, 20, 0, 0), + sales_count=2, + total_sats=5000, + totals_by_currency={"brl": 10.0}, + business_vat_id="123.456.789-00", + lang="br", + ) + lines = text.splitlines() + + assert lines[0] == "RESUMO DIÁRIO" + assert "Vendas: 2" in lines + assert "Total (BRL): 10.00" in lines + assert "Obrigado!" in lines + assert "Doc. Fiscal: 123.456.789-00" in lines + + +def test_render_summary_text_unknown_lang_falls_back_to_english(): + text = render_summary_text( + start=datetime(2026, 7, 19, 0, 0), + end=datetime(2026, 7, 20, 0, 0), + sales_count=1, + total_sats=100, + totals_by_currency={}, + lang="xx", + ) + + assert text.splitlines()[0] == "DAILY SUMMARY" + + +def test_render_summary_text_locale_variants_normalize(): + # LNbits UI locales are forwarded verbatim; region suffixes and Portuguese + # variants must still resolve to the right summary language. + def title(lang): + return render_summary_text( + start=datetime(2026, 7, 19, 0, 0), + end=datetime(2026, 7, 20, 0, 0), + sales_count=0, + total_sats=0, + totals_by_currency={}, + lang=lang, + ).splitlines()[0] + + for pt in ("br", "BR", "pt", "pt-BR", "pt_br"): + assert title(pt) == "RESUMO DIÁRIO", pt + for en in ("en", "en-US", None): + assert title(en) == "DAILY SUMMARY", en diff --git a/views_api.py b/views_api.py index 189897e..67fffb8 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from http import HTTPStatus from time import time from typing import Any, Literal @@ -46,6 +46,7 @@ get_latest_tpos_payments, get_tpos, get_tpos_payment_by_hash, + get_tpos_payments_between, get_tposs, update_tpos, ) @@ -68,8 +69,10 @@ ReceiptPrint, TapToPay, Tpos, + TposDailySummary, TposInvoiceResponse, TposPayment, + render_summary_text, ) from .services import ( fetch_onchain_address, @@ -148,6 +151,56 @@ def _build_receipt_data( ) +MAX_SUMMARY_RANGE = timedelta(days=31) + + +async def _build_daily_summary( + tpos: Tpos, tpos_id: str, start: datetime, end: datetime, lang: str = "en" +) -> TposDailySummary: + if end <= start or end - start > MAX_SUMMARY_RANGE: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid range: end must be after start and span at most 31 days.", + ) + tpos_payments = await get_tpos_payments_between(tpos_id, start, end) + + total_sats = 0 + totals_by_currency: dict[str, float] = {} + for tpos_payment in tpos_payments: + total_sats += tpos_payment.amount + payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True) + if not payment: + continue + details = payment.extra.get("details") or {} + currency = details.get("currency") + exchange_rate = details.get("exchangeRate") or payment.extra.get("exchangeRate") + if currency and exchange_rate: + totals_by_currency[currency] = totals_by_currency.get( + currency, 0.0 + ) + tpos_payment.amount / float(exchange_rate) + + print_text = render_summary_text( + start=start, + end=end, + sales_count=len(tpos_payments), + total_sats=total_sats, + totals_by_currency=totals_by_currency, + business_name=tpos.business_name, + business_address=tpos.business_address, + business_vat_id=tpos.business_vat_id, + lang=lang, + ) + return TposDailySummary( + tpos_id=tpos_id, + start=start, + end=end, + sales_count=len(tpos_payments), + total_sats=total_sats, + totals_by_currency=totals_by_currency, + print_text=print_text, + ) + + async def _get_watchonly_status(wallet) -> dict[str, Any]: if not await watchonly_available_for_user(wallet.user): return { @@ -869,6 +922,52 @@ async def api_tpos_print_invoice( return {"success": True} +@tpos_api_router.get( + "/api/v1/tposs/{tpos_id}/summary", status_code=HTTPStatus.OK +) +async def api_tpos_get_daily_summary( + tpos_id: str, + start: datetime = Query(...), + end: datetime = Query(...), + lang: str = Query("en"), +) -> TposDailySummary: + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + return await _build_daily_summary(tpos, tpos_id, start, end, lang) + + +@tpos_api_router.post( + "/api/v1/tposs/{tpos_id}/summary/print", status_code=HTTPStatus.OK +) +async def api_tpos_print_daily_summary( + tpos_id: str, + start: datetime = Query(...), + end: datetime = Query(...), + lang: str = Query("en"), +): + tpos = await get_tpos(tpos_id) + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + summary = await _build_daily_summary(tpos, tpos_id, start, end, lang) + payload = ReceiptPrint( + tpos_id=tpos_id, + receipt_type="summary", + print_text=summary.print_text, + receipt={ + **summary.dict(), + "start": summary.start.isoformat(), + "end": summary.end.isoformat(), + }, + ) + 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,