diff --git a/app/mailer.py b/app/mailer.py new file mode 100644 index 0000000..53f8345 --- /dev/null +++ b/app/mailer.py @@ -0,0 +1,60 @@ +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"] diff --git a/app/main.py b/app/main.py index 62b2298..1518195 100644 --- a/app/main.py +++ b/app/main.py @@ -74,6 +74,8 @@ _STARTUP_DDL = [ "ALTER TABLE sales ADD COLUMN IF NOT EXISTS hold_expires_at timestamptz", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS split_payments jsonb", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0", + "ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_tendered numeric(10,2)", + "ALTER TABLE sales ADD COLUMN IF NOT EXISTS notes text", "ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0", "ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)", # auto-id for new POS rows (migrated tables came without sequences) diff --git a/app/receipts.py b/app/receipts.py new file mode 100644 index 0000000..f9bde15 --- /dev/null +++ b/app/receipts.py @@ -0,0 +1,84 @@ +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' −{m(disc)}' if disc > 0 else "" + rows += (f'{i.get("qty",1)}× {_e(i.get("item_name") or i.get("sku"))}{dtag}' + f'{m(i.get("line_total"))}') + + 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'Subtotal{m(sub)}' + if disc_total > 0: + summ += f'Discount−{m(disc_total)}' + if tax > 0: + summ += f'GST{" incl" if incl else ""} ({st.get("tax_rate","0")}%){m(tax)}' + if trade > 0: + summ += f'Trade-in credit−{m(trade)}' + + # 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'{_e(p["method"].title())}{m(p["amount"])}' + else: + pay += f'{_e(method.title() or "Paid")}{m(sale.get("amount_paid", tot))}' + tendered = sale.get("amount_tendered") + if method == "cash" and tendered: + pay += f'Tendered{m(tendered)}' + pay += f'Change{m(float(tendered) - tot)}' + + bal = round(tot - float(sale.get("amount_paid") or 0), 2) + bal_line = (f'Balance due' + f'{m(bal)}') if bal > 0.005 else "" + + notes = f'
{_e(sale.get("notes"))}
' if sale.get("notes") else "" + + return f"""
+ +
{_e(store)}
+{f'
{_e(addr)}
' if addr else ''} +
{_e(sale.get('sale_number',''))} · {_e(date)} · {_e(who)}
+
+{rows}
+
+{summ}{pay}{bal_line}
Total{m(tot)}
+{notes} +
+
{_e(footer)}
+
""" diff --git a/app/sales_routes.py b/app/sales_routes.py index e80c1c4..6695763 100644 --- a/app/sales_routes.py +++ b/app/sales_routes.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text +from . import mailer, receipts from .auth import require_token from .db import get_db @@ -30,6 +31,8 @@ class SaleIn(BaseModel): split: list[dict] | None = None hold: dict | None = None trade_in: float = 0 + tendered: float | None = None # cash given (for the change line on the receipt) + notes: str | None = None customer_id: int | None = None # 0 = guest sale; None when unset @@ -85,15 +88,15 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0) sale_id = (await db.execute(text(""" INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total, - status, payment_method, payment_status, amount_paid, trade_in_credit, - hold_expires_at, split_payments, sale_date, created_at) - VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade, - :due, CAST(:split AS jsonb), now(), now()) + status, payment_method, payment_status, amount_paid, amount_tendered, trade_in_credit, + notes, hold_expires_at, split_payments, sale_date, created_at) + VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :tend, :trade, + :notes, :due, CAST(:split AS jsonb), now(), now()) RETURNING id """), {"sn": sale_number, "cust": cust, "sub": gross - line_disc, "disc": line_disc + body.cart_discount, "tax": tax, "total": total, "st": status, "pm": body.payment_method, "ps": pay_status, - "paid": amount_paid, "trade": body.trade_in, "due": hold_due, - "split": json.dumps(body.split) if body.split else None})).first()[0] + "paid": amount_paid, "tend": body.tendered, "trade": body.trade_in, "notes": body.notes, + "due": hold_due, "split": json.dumps(body.split) if body.split else None})).first()[0] for li in body.items: await db.execute(text(""" @@ -131,15 +134,77 @@ async def list_sales(status: str = Query(""), ident=Depends(require_token), db=D return {"sales": [dict(r) for r in rows.mappings()]} -@router.get("/{sale_id}") -async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)): +async def _load_sale(db, sale_id): s = (await db.execute(text("SELECT * FROM sales WHERE id=:i"), {"i": sale_id})).mappings().first() if not s: raise HTTPException(404, "not found") + s = dict(s) items = [dict(r) for r in (await db.execute(text( "SELECT sku, item_name, qty, unit_price::float AS unit_price, line_total::float AS line_total, " "discount_amount::float AS discount FROM sale_items WHERE sale_id=:i"), {"i": sale_id})).mappings()] - return {"sale": dict(s), "items": items} + cust = None + if s.get("customer_id"): + c = (await db.execute(text( + "SELECT trim(first_name||' '||coalesce(last_name,'')) AS name, email FROM customer WHERE id=:i"), + {"i": s["customer_id"]})).mappings().first() + cust = dict(c) if c else None + return s, items, cust + + +@router.get("/{sale_id}") +async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)): + s, items, cust = await _load_sale(db, sale_id) + return {"sale": s, "items": items, "customer": cust} + + +@router.get("/{sale_id}/receipt") +async def sale_receipt(sale_id: int, ident=Depends(require_token), db=Depends(get_db)): + """Rendered receipt HTML (for the print window) + the customer's email if on file.""" + s, items, cust = await _load_sale(db, sale_id) + html = receipts.render_receipt_html(s, items, cust, await _settings(db)) + return {"html": html, "sale_number": s.get("sale_number"), + "customer_email": (cust or {}).get("email")} + + +class EmailIn(BaseModel): + email: str + + +@router.post("/{sale_id}/email") +async def email_receipt(sale_id: int, body: EmailIn, ident=Depends(require_token), db=Depends(get_db)): + if "@" not in body.email: + raise HTTPException(422, "invalid email") + s, items, cust = await _load_sale(db, sale_id) + st = await _settings(db) + html = receipts.render_receipt_html(s, items, cust, st) + subject = f"{st['store_name']} receipt — {s.get('sale_number', '')}" + try: + sender = await mailer.send_mail(db, body.email, subject, html) + except mailer.MailUnconfigured as e: + raise HTTPException(503, str(e)) + except Exception as e: + raise HTTPException(502, f"send failed: {e}") + return {"ok": True, "sent_to": body.email, "from": sender} + + +@router.post("/email-test") +async def email_test(body: EmailIn, ident=Depends(require_token), db=Depends(get_db)): + """Send a sample receipt to verify the SMTP setup from the Settings tab.""" + if "@" not in body.email: + raise HTTPException(422, "invalid email") + st = await _settings(db) + sample = {"sale_number": "TEST-0001", "sale_date": datetime.now().isoformat(), + "subtotal": 28.0, "discount_amount": 3.0, "tax_amount": 0, "total": 25.0, + "payment_method": "cash", "amount_paid": 25.0, "amount_tendered": 30.0} + items = [{"qty": 1, "item_name": "Selected Ambient Works (test)", "line_total": 28.0, "discount": 3.0}] + html = receipts.render_receipt_html(sample, items, {"name": "Test"}, st) + try: + sender = await mailer.send_mail(db, body.email, f"{st['store_name']} — test receipt", html) + except mailer.MailUnconfigured as e: + raise HTTPException(503, str(e)) + except Exception as e: + raise HTTPException(502, f"send failed: {e}") + return {"ok": True, "sent_to": body.email, "from": sender} @router.post("/{sale_id}/pay") @@ -218,20 +283,29 @@ async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Dep # ── Settings (currency / tax / default discount) ───────────────────────────────────────────── -@router.get("/settings") -async def get_settings(ident=Depends(require_token), db=Depends(get_db)): +async def _settings(db): rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active")) s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()} return {"currency_symbol": s.get("currency_symbol", "$"), "tax_rate": s.get("default_tax_rate", s.get("tax_rate", "0")), "discount_rate": s.get("default_discount_rate", "0"), - "tax_type": s.get("tax_type", "exclusive")} + "tax_type": s.get("tax_type", "exclusive"), + "store_name": s.get("store_name", "RecordGod"), + "store_address": s.get("store_address", ""), + "receipt_footer": s.get("receipt_footer", "thanks for digging")} + + +@router.get("/settings") +async def get_settings(ident=Depends(require_token), db=Depends(get_db)): + return await _settings(db) @router.post("/settings") async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)): m = {"currency_symbol": body.get("currency_symbol"), "default_tax_rate": body.get("tax_rate"), - "default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type")} + "default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type"), + "store_name": body.get("store_name"), "store_address": body.get("store_address"), + "receipt_footer": body.get("receipt_footer")} for k, v in m.items(): if v is None: continue diff --git a/app/settings_routes.py b/app/settings_routes.py index 0b4f708..4ef9498 100644 --- a/app/settings_routes.py +++ b/app/settings_routes.py @@ -22,6 +22,12 @@ KNOWN = { "discogs_token": "Discogs personal access token", "cloudflare_api_token": "Cloudflare API token", "dealgod_api_key": "DealGod API key (X-Api-Key)", + "smtp_host": "Receipt email — SMTP host (e.g. mail.monsterrobot.party)", + "smtp_port": "Receipt email — SMTP port (587 STARTTLS / 465 SSL)", + "smtp_user": "Receipt email — SMTP username (e.g. shop@monsterrobot.party)", + "smtp_pass": "Receipt email — SMTP password", + "smtp_from": "Receipt email — From address (defaults to username)", + "smtp_from_name": "Receipt email — From name (e.g. Monster Robot Records)", } diff --git a/site/pos.html b/site/pos.html index 1384fb7..e0f6fda 100644 --- a/site/pos.html +++ b/site/pos.html @@ -3,244 +3,246 @@ -RecordGod — sales +RecordGod — Sales -

RecordGod · sales

+

RecordGod · Sales