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 @@