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>
This commit is contained in:
223
local_backend/routers/tabs.py
Normal file
223
local_backend/routers/tabs.py
Normal file
@@ -0,0 +1,223 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user