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>
61 lines
2.0 KiB
Python
61 lines
2.0 KiB
Python
import asyncio
|
|
import smtplib
|
|
import ssl
|
|
from email.message import EmailMessage
|
|
|
|
from . import vault
|
|
|
|
# Receipt email. SMTP creds live in the vault (admin-only). stdlib smtplib in a thread so the
|
|
# event loop isn't blocked — no new dependency. ponytail: one sender, swap to API mail if needed.
|
|
|
|
|
|
class MailUnconfigured(Exception):
|
|
pass
|
|
|
|
|
|
async def _creds(db):
|
|
host = await vault.get_secret(db, "smtp_host")
|
|
if not host:
|
|
raise MailUnconfigured("SMTP not configured — set smtp_* in admin → Connections")
|
|
user = await vault.get_secret(db, "smtp_user") or ""
|
|
return {
|
|
"host": host.strip(),
|
|
"port": int((await vault.get_secret(db, "smtp_port") or "587").strip() or 587),
|
|
"user": user,
|
|
"pass": await vault.get_secret(db, "smtp_pass") or "",
|
|
"from": (await vault.get_secret(db, "smtp_from") or user).strip(),
|
|
"from_name": await vault.get_secret(db, "smtp_from_name") or "RecordGod",
|
|
}
|
|
|
|
|
|
def _send_sync(c, to, subject, html):
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
msg["From"] = f'{c["from_name"]} <{c["from"]}>' if c["from_name"] else c["from"]
|
|
msg["To"] = to
|
|
msg.set_content("Your receipt is below — view this email in an HTML-capable client.")
|
|
msg.add_alternative(html, subtype="html")
|
|
ctx = ssl.create_default_context()
|
|
if c["port"] == 465:
|
|
with smtplib.SMTP_SSL(c["host"], c["port"], context=ctx, timeout=20) as s:
|
|
if c["user"]:
|
|
s.login(c["user"], c["pass"])
|
|
s.send_message(msg)
|
|
else:
|
|
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
|
|
s.ehlo()
|
|
try:
|
|
s.starttls(context=ctx)
|
|
s.ehlo()
|
|
except smtplib.SMTPNotSupportedError:
|
|
pass
|
|
if c["user"]:
|
|
s.login(c["user"], c["pass"])
|
|
s.send_message(msg)
|
|
|
|
|
|
async def send_mail(db, to, subject, html):
|
|
c = await _creds(db)
|
|
await asyncio.to_thread(_send_sync, c, to, subject, html)
|
|
return c["from"]
|