Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crud.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from datetime import datetime
from typing import Any

from lnbits.db import Database
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
""")
87 changes: 85 additions & 2 deletions models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
57 changes: 57 additions & 0 deletions static/js/tpos.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions templates/tpos/dialogs.html
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ <h5 class="q-mt-none q-mb-sm">
<q-dialog v-model="lastPaymentsDialog.show" position="bottom">
<q-card class="lnbits__dialog-card">
<q-card-section class="row items-center q-pb-sm">
<q-btn
icon="receipt_long"
:label="printSummaryLabel"
color="primary"
unelevated
no-caps
@click="printDailySummary"
></q-btn>
<q-space></q-space>
<q-btn icon="close" size="sm" flat round dense v-close-popup></q-btn>
</q-card-section>
Expand Down
90 changes: 90 additions & 0 deletions tests/test_summary.py
Original file line number Diff line number Diff line change
@@ -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
Loading