Files
xenia-pos-local/local_backend/routers/tabs.py
bonamin 2ff48730f7 feat: Phase 2G — pay-later tab system
Backend:
- New models: Tab, TabEntry, TabPayment (3 new tables)
  - Tab: open|closed|forgiven, one open tab per customer enforced
  - TabEntry: what went ON the tab (per-item snapshot with description)
  - TabPayment: what came OFF the tab; balance = sum(entries) - sum(payments), never stored
- New router /api/tabs/:
  - GET / — list open tabs (sorted by balance desc); ?tab_status= override
  - GET /{id} — full tab detail with entries + payments
  - POST / — open new tab for a customer (400 if one already exists)
  - POST /{id}/entries — add entry directly (amount + description)
  - POST /{id}/pay — record payment; validates amount <= balance
  - POST /{id}/close — close tab (requires balance = 0)
  - POST /{id}/forgive — write off remaining balance
  - GET /customer/{customer_id} — all tabs for a customer (open first)
- POST /api/orders/{id}/items/{item_id}/tab:
  - Requires order has a customer assigned
  - Finds or auto-creates open tab for that customer
  - Sets order_item.status = "tabbed" (new valid status value)
  - Creates TabEntry with auto-generated description
  - Broadcasts order_updated SSE event
- Migrations: CREATE TABLE IF NOT EXISTS for tabs, tab_entries, tab_payments

Frontend:
- TabsPage (/tabs): open tabs list sorted by balance; each card shows charges/payments
  history, live balance, Πληρωμή modal (defaults to full balance), Κλείσιμο button
  (only shown when balance=0), Χάρισμα Υπολοίπου with confirm modal + reason
- CustomersPage CustomerDetail: open tab banner showing balance + entry count
  (appears between stats and contact info when customer has an open tab)
- OrderDetailPage: 📋 Καρτέλα button appears on active items when order has a customer;
  tabbed items show a 📋 Καρτέλα badge; tabItem mutation calls /items/{id}/tab
- Sidebar: CreditCard icon for Καρτέλες (after Πελάτες)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 03:07:22 +03:00

224 lines
7.9 KiB
Python

from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models.tabs import Tab, TabEntry, TabPayment
from models.customers import Customer
from models.order import Order, OrderItem
from models.user import User
from schemas.tabs import TabOut, TabEntryOut, TabPaymentOut, TabPayRequest, TabForgiveRequest, TabCloseRequest
from routers.deps import require_manager
router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
def _tab_out(tab: Tab) -> dict:
c = tab.customer
customer_name = c.name if c else None
if c and c.nickname:
customer_name = f"{c.name} «{c.nickname}»"
customer_phone = c.phone if c else None
total_charged = round(sum(e.amount for e in tab.entries), 2)
total_paid = round(sum(p.amount for p in tab.payments), 2)
balance = round(total_charged - total_paid, 2)
closed_by_name = None
if tab.closed_by:
closed_by_name = tab.closed_by.full_name or tab.closed_by.username
entries_out = []
for e in sorted(tab.entries, key=lambda x: x.created_at):
creator = e.created_by.full_name or e.created_by.username if e.created_by else None
entries_out.append({
"id": e.id, "tab_id": e.tab_id, "order_id": e.order_id,
"order_item_id": e.order_item_id, "amount": e.amount,
"description": e.description, "created_by_id": e.created_by_id,
"created_by_name": creator, "created_at": e.created_at,
})
payments_out = []
for p in sorted(tab.payments, key=lambda x: x.created_at):
receiver = p.received_by.full_name or p.received_by.username if p.received_by else None
payments_out.append({
"id": p.id, "tab_id": p.tab_id, "amount": p.amount,
"payment_method": p.payment_method, "received_by_id": p.received_by_id,
"received_by_name": receiver, "notes": p.notes, "created_at": p.created_at,
})
return {
"id": tab.id,
"customer_id": tab.customer_id,
"customer_name": customer_name,
"customer_phone": customer_phone,
"status": tab.status,
"opened_at": tab.opened_at,
"closed_at": tab.closed_at,
"closed_by_id": tab.closed_by_id,
"notes": tab.notes,
"balance": balance,
"total_charged": total_charged,
"total_paid": total_paid,
"entries": entries_out,
"payments": payments_out,
}
@router.get("/", response_model=List[TabOut])
def list_tabs(
tab_status: Optional[str] = None,
customer_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Tab)
if tab_status:
q = q.filter(Tab.status == tab_status)
else:
q = q.filter(Tab.status == "open") # default: only open tabs
if customer_id:
q = q.filter(Tab.customer_id == customer_id)
tabs = q.order_by(Tab.opened_at.desc()).all()
result = [_tab_out(t) for t in tabs]
# Sort open tabs by balance descending
result.sort(key=lambda x: x["balance"], reverse=True)
return result
@router.get("/{tab_id}", response_model=TabOut)
def get_tab(tab_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
tab = db.query(Tab).filter(Tab.id == tab_id).first()
if not tab:
raise HTTPException(status_code=404, detail="Tab not found")
return _tab_out(tab)
@router.post("/", response_model=TabOut, status_code=status.HTTP_201_CREATED)
def open_tab(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
customer = db.query(Customer).filter(Customer.id == customer_id, Customer.is_active == True).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
existing = db.query(Tab).filter(Tab.customer_id == customer_id, Tab.status == "open").first()
if existing:
raise HTTPException(status_code=400, detail="Customer already has an open tab")
tab = Tab(customer_id=customer_id)
db.add(tab)
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/entries", response_model=TabOut)
def add_tab_entry(
tab_id: int,
order_id: Optional[int] = None,
order_item_id: Optional[int] = None,
amount: float = 0.0,
description: Optional[str] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
if amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")
entry = TabEntry(
tab_id=tab_id, order_id=order_id, order_item_id=order_item_id,
amount=amount, description=description, created_by_id=user.id,
)
db.add(entry)
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/pay", response_model=TabOut)
def pay_tab(
tab_id: int,
body: TabPayRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
if body.amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")
total_charged = sum(e.amount for e in tab.entries)
total_paid = sum(p.amount for p in tab.payments)
balance = total_charged - total_paid
if body.amount > balance + 0.005:
raise HTTPException(status_code=400, detail=f"Payment exceeds balance ({balance:.2f})")
payment = TabPayment(
tab_id=tab_id, amount=body.amount,
payment_method=body.payment_method, notes=body.notes,
received_by_id=user.id,
)
db.add(payment)
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/close", response_model=TabOut)
def close_tab(
tab_id: int,
body: TabCloseRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
total_charged = sum(e.amount for e in tab.entries)
total_paid = sum(p.amount for p in tab.payments)
balance = round(total_charged - total_paid, 2)
if balance > 0.005:
raise HTTPException(status_code=400, detail=f"Tab still has a balance of €{balance:.2f} — pay it off first or use /forgive")
tab.status = "closed"
tab.closed_at = _utcnow()
tab.closed_by_id = user.id
if body.notes:
tab.notes = body.notes
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/forgive", response_model=TabOut)
def forgive_tab(
tab_id: int,
body: TabForgiveRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
tab.status = "forgiven"
tab.closed_at = _utcnow()
tab.closed_by_id = user.id
if body.reason:
tab.notes = body.reason
db.commit()
db.refresh(tab)
return _tab_out(tab)
# ── Customer tabs ──────────────────────────────────────────────────────────────
@router.get("/customer/{customer_id}", response_model=List[TabOut])
def customer_tabs(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
tabs = db.query(Tab).filter(Tab.customer_id == customer_id).order_by(
Tab.status.asc(), # open first
Tab.opened_at.desc(),
).all()
return [_tab_out(t) for t in tabs]