RECORDGOD/app/receipts.py
type-two 5351cfb9c2 feat(pos): rebuild Sales — 3 tabs, smooth checkout + tender/change, print+email receipts
7 tabs → Register / History / Settings. Register: search/scan + cart + checkout flow
(cash tender→change, split tenders, layby deposit, notes) → completes → receipt auto-pops.
Receipts now server-rendered (app/receipts.py, one template) with per-item discounts, GST
breakdown, split lines, tendered/change, store header/footer; Print opens a clean window
(no visibility hack); Email via app/mailer.py (stdlib SMTP, creds in vault, admin-only) with
a Settings 'send test' button. History merges past sales + laybys (reprint/collect). Settings
holds currency/GST/discount + receipt header/footer + promotions. sales gains amount_tendered
+ notes; SaleIn carries tendered/notes; /sales/{id}/receipt + /{id}/email + /email-test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:29:42 +10:00

85 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import html as _html
import json
def _e(s):
return _html.escape(str(s if s is not None else ""))
def render_receipt_html(sale, items, customer, st):
"""One self-contained receipt (inline CSS) used by both the print window and the email body."""
cur = st.get("currency_symbol", "$")
def m(v):
try:
return f"{cur}{float(v or 0):.2f}"
except (TypeError, ValueError):
return f"{cur}0.00"
store = st.get("store_name") or "RecordGod"
addr = st.get("store_address") or ""
footer = st.get("receipt_footer") or "thanks for digging"
date = str(sale.get("sale_date") or "")[:16].replace("T", " ")
who = (customer or {}).get("name") or "Guest"
rows = ""
for i in items:
disc = float(i.get("discount") or 0)
dtag = f' <span style="color:#c2185b">{m(disc)}</span>' if disc > 0 else ""
rows += (f'<tr><td>{i.get("qty",1)}× {_e(i.get("item_name") or i.get("sku"))}{dtag}</td>'
f'<td style="text-align:right">{m(i.get("line_total"))}</td></tr>')
sub = sale.get("subtotal")
disc_total = float(sale.get("discount_amount") or 0)
tax = float(sale.get("tax_amount") or 0)
incl = (st.get("tax_type") or "exclusive") == "inclusive"
trade = float(sale.get("trade_in_credit") or 0)
tot = float(sale.get("total") or 0)
summ = f'<tr><td>Subtotal</td><td style="text-align:right">{m(sub)}</td></tr>'
if disc_total > 0:
summ += f'<tr><td>Discount</td><td style="text-align:right">{m(disc_total)}</td></tr>'
if tax > 0:
summ += f'<tr><td>GST{" incl" if incl else ""} ({st.get("tax_rate","0")}%)</td><td style="text-align:right">{m(tax)}</td></tr>'
if trade > 0:
summ += f'<tr><td>Trade-in credit</td><td style="text-align:right">{m(trade)}</td></tr>'
# split-payment lines, else the single method + (cash) tender/change
pay = ""
method = sale.get("payment_method") or ""
splits = sale.get("split_payments")
if isinstance(splits, str):
try:
splits = json.loads(splits)
except ValueError:
splits = None
if splits and isinstance(splits, list):
for p in splits:
if p.get("method") and p.get("amount"):
pay += f'<tr><td>{_e(p["method"].title())}</td><td style="text-align:right">{m(p["amount"])}</td></tr>'
else:
pay += f'<tr><td>{_e(method.title() or "Paid")}</td><td style="text-align:right">{m(sale.get("amount_paid", tot))}</td></tr>'
tendered = sale.get("amount_tendered")
if method == "cash" and tendered:
pay += f'<tr><td>Tendered</td><td style="text-align:right">{m(tendered)}</td></tr>'
pay += f'<tr><td>Change</td><td style="text-align:right">{m(float(tendered) - tot)}</td></tr>'
bal = round(tot - float(sale.get("amount_paid") or 0), 2)
bal_line = (f'<tr><td style="color:#c2185b">Balance due</td>'
f'<td style="text-align:right;color:#c2185b">{m(bal)}</td></tr>') if bal > 0.005 else ""
notes = f'<div class="ct" style="margin-top:6px">{_e(sale.get("notes"))}</div>' if sale.get("notes") else ""
return f"""<div class="receipt-container" style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.6;color:#111;max-width:300px;margin:0 auto;padding:6px">
<style>.receipt-container td{{padding:1px 0}} .receipt-container table{{width:100%;border-collapse:collapse}} .receipt-container .hr{{border-top:1px dashed #999;margin:7px 0}} .receipt-container .ct{{text-align:center}} @media print{{.receipt-actions{{display:none}}}}</style>
<div class="ct" style="font-weight:700;font-size:15px">{_e(store)}</div>
{f'<div class="ct" style="color:#555">{_e(addr)}</div>' if addr else ''}
<div class="ct" style="color:#555">{_e(sale.get('sale_number',''))} · {_e(date)} · {_e(who)}</div>
<div class="hr"></div>
<table>{rows}</table>
<div class="hr"></div>
<table>{summ}<tr style="font-weight:700"><td>Total</td><td style="text-align:right">{m(tot)}</td></tr>{pay}{bal_line}</table>
{notes}
<div class="hr"></div>
<div class="ct" style="color:#555">{_e(footer)}</div>
</div>"""