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)}
+
+
+
+
{summ}| Total | {m(tot)} |
{pay}{bal_line}
+{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
+
-
Sales Management
-
-
-
-
-
-
-
-
+
+
RecordGod Sales
+
+
+
+
+
+
-
-
-
-
-
-
Customer
-
👤 Guest Sale
-
-
-
-
- or
-
-
-
-
-
-
-
+
+
+
-
Scan / enter SKU · Release ID · Barcode
-
-
-
-
-
+
-
🛒 Cart
+
+
cart is empty — scan or search to add items
-
+
-
Total
+
Subtotal$0.00
-
Cart discount $
-
Trade-in credit
-
Tax %
-
Total$0.00
-
-
-
-
+
Manual discount
+
Trade-in credit
+
Tax %
+
Total$0.00
+
+
+
+
+
+
+
-
+
+
+
Tendered
+
Change$0.00
+
+
+
+
+
+
+
-
Promotions on now — toggle in Promotions tab
-
none active
+
+
+
+
+
+
+
+
+
+
Promotions on now — manage in Settings
+
none active
-
-
-
💳 Put current cart on a payment plan
-
Deposit
- Due date
-
-
Builds from the Sales-tab cart: reserves the items, banks the deposit, balance due by the date.
-
-
-
📋 Open plans & holds
loading…
-
-
-
-
+
+
+
📋 Open laybys & holds
loading…
Past sales
-
-
| Sale # | Date | Customer | Items | Total | Status | |
+
+ | Sale # | Date | Customer | Items | Total | Status | |
-
-
-
-
-
-
-
-
-
Bulk import sale
-
One SKU / Release ID / barcode per line — resolves each to in-stock inventory, rings them up as one sale and marks them sold.
-
-
-
-
-
-
-
-
+
-
Sales settings
-
Currency
-
Tax %
-
-
Default discount %
-
-
+
+
✉️ Receipt email — test
+
+
+
+
+
Promotions & discounts — switch on to apply at the counter
+
loading…
-
-
+
+