diff --git a/local_backend/main.py b/local_backend/main.py
index a7ca7fd..722f131 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -23,6 +23,7 @@ import models.reservation # noqa: F401
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
import models.customers # noqa: F401 — registers Customer
+import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -37,6 +38,7 @@ from routers import reservations as reservations_router
from routers import notes as notes_router
from routers.expenses import contacts_router, expenses_router
from routers import customers as customers_router
+from routers import tabs as tabs_router
def _run_migrations():
@@ -275,6 +277,35 @@ def _run_migrations():
# Phase 2B — staff payroll
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
+ # Phase 2G — pay-later tab system
+ """CREATE TABLE IF NOT EXISTS tabs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ customer_id INTEGER NOT NULL REFERENCES customers(id),
+ status VARCHAR NOT NULL DEFAULT 'open',
+ opened_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ closed_at DATETIME,
+ closed_by_id INTEGER REFERENCES users(id),
+ notes TEXT
+ )""",
+ """CREATE TABLE IF NOT EXISTS tab_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tab_id INTEGER NOT NULL REFERENCES tabs(id),
+ order_id INTEGER REFERENCES orders(id),
+ order_item_id INTEGER REFERENCES order_items(id),
+ amount REAL NOT NULL,
+ description TEXT,
+ created_by_id INTEGER NOT NULL REFERENCES users(id),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )""",
+ """CREATE TABLE IF NOT EXISTS tab_payments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tab_id INTEGER NOT NULL REFERENCES tabs(id),
+ amount REAL NOT NULL,
+ payment_method VARCHAR,
+ received_by_id INTEGER NOT NULL REFERENCES users(id),
+ notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )""",
# Phase 2F — customer CRM
"""CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -412,3 +443,4 @@ app.include_router(notes_router.router, prefix="/api/notes", tag
app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
+app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
diff --git a/local_backend/models/tabs.py b/local_backend/models/tabs.py
new file mode 100644
index 0000000..b4cdef0
--- /dev/null
+++ b/local_backend/models/tabs.py
@@ -0,0 +1,58 @@
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
+from sqlalchemy.orm import relationship
+from datetime import datetime, timezone
+from database import Base
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+class Tab(Base):
+ __tablename__ = "tabs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
+ status = Column(String, default="open", nullable=False) # open | closed | forgiven
+ opened_at = Column(DateTime(timezone=True), default=_utcnow)
+ closed_at = Column(DateTime(timezone=True), nullable=True)
+ closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
+ notes = Column(Text, nullable=True)
+
+ customer = relationship("Customer", foreign_keys=[customer_id])
+ closed_by = relationship("User", foreign_keys=[closed_by_id])
+ entries = relationship("TabEntry", back_populates="tab", cascade="all, delete-orphan")
+ payments = relationship("TabPayment", back_populates="tab", cascade="all, delete-orphan")
+
+
+class TabEntry(Base):
+ """What went ON the tab — a deferred charge."""
+ __tablename__ = "tab_entries"
+
+ id = Column(Integer, primary_key=True, index=True)
+ tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False)
+ order_id = Column(Integer, ForeignKey("orders.id"), nullable=True)
+ order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True)
+ amount = Column(Float, nullable=False) # snapshot at time of tab entry
+ description = Column(Text, nullable=True) # auto-generated summary
+ created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ created_at = Column(DateTime(timezone=True), default=_utcnow)
+
+ tab = relationship("Tab", back_populates="entries")
+ created_by = relationship("User", foreign_keys=[created_by_id])
+
+
+class TabPayment(Base):
+ """What came OFF the tab — a payment reducing the balance."""
+ __tablename__ = "tab_payments"
+
+ id = Column(Integer, primary_key=True, index=True)
+ tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False)
+ amount = Column(Float, nullable=False)
+ payment_method = Column(String, nullable=True) # cash | card | other
+ received_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ notes = Column(Text, nullable=True)
+ created_at = Column(DateTime(timezone=True), default=_utcnow)
+
+ tab = relationship("Tab", back_populates="payments")
+ received_by = relationship("User", foreign_keys=[received_by_id])
diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py
index b2f3376..322bffc 100644
--- a/local_backend/routers/orders.py
+++ b/local_backend/routers/orders.py
@@ -583,6 +583,10 @@ class AssignCustomerBody(BaseModel):
customer_id: Optional[int] = None # null = unassign
+class TabItemBody(BaseModel):
+ pass # no body needed — tab derived from order's customer
+
+
@router.put("/{order_id}/customer")
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
from models.customers import Customer
@@ -958,3 +962,55 @@ def print_synopsis(
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width)
return {"status": "printing"}
+
+
+# ─── Phase 2G: Put item on customer tab ───────────────────────────────────────
+
+@router.post("/{order_id}/items/{item_id}/tab")
+def put_item_on_tab(
+ order_id: int,
+ item_id: int,
+ db: Session = Depends(get_db),
+ user: User = Depends(require_manager),
+):
+ from models.tabs import Tab, TabEntry
+
+ order = db.query(Order).filter(Order.id == order_id).first()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ if not order.customer_id:
+ raise HTTPException(status_code=400, detail="Order has no customer assigned — assign a customer first")
+
+ item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+ if item.status != "active":
+ raise HTTPException(status_code=400, detail=f"Item is already {item.status} — cannot tab it")
+
+ # Find or auto-create the customer's open tab
+ tab = db.query(Tab).filter(Tab.customer_id == order.customer_id, Tab.status == "open").first()
+ if not tab:
+ tab = Tab(customer_id=order.customer_id)
+ db.add(tab)
+ db.flush()
+
+ # Build description
+ product_name = item.product.name if item.product else f"#{item.product_id}"
+ description = f"{product_name} ×{item.quantity} @ €{item.unit_price:.2f}"
+
+ entry = TabEntry(
+ tab_id=tab.id,
+ order_id=order_id,
+ order_item_id=item_id,
+ amount=round(item.unit_price * item.quantity, 2),
+ description=description,
+ created_by_id=user.id,
+ )
+ db.add(entry)
+
+ # Mark item as tabbed — distinct from active/paid/cancelled
+ item.status = "tabbed"
+
+ db.commit()
+ broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "item_tabbed"})
+ return {"status": "tabbed", "tab_id": tab.id, "entry_amount": entry.amount}
diff --git a/local_backend/routers/tabs.py b/local_backend/routers/tabs.py
new file mode 100644
index 0000000..c4aeef1
--- /dev/null
+++ b/local_backend/routers/tabs.py
@@ -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]
diff --git a/local_backend/schemas/tabs.py b/local_backend/schemas/tabs.py
new file mode 100644
index 0000000..f0d4953
--- /dev/null
+++ b/local_backend/schemas/tabs.py
@@ -0,0 +1,63 @@
+from pydantic import BaseModel
+from typing import Optional, List
+from datetime import datetime
+
+
+class TabEntryOut(BaseModel):
+ id: int
+ tab_id: int
+ order_id: Optional[int] = None
+ order_item_id: Optional[int] = None
+ amount: float
+ description: Optional[str] = None
+ created_by_id: int
+ created_by_name: Optional[str] = None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class TabPaymentOut(BaseModel):
+ id: int
+ tab_id: int
+ amount: float
+ payment_method: Optional[str] = None
+ received_by_id: int
+ received_by_name: Optional[str] = None
+ notes: Optional[str] = None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class TabOut(BaseModel):
+ id: int
+ customer_id: int
+ customer_name: Optional[str] = None
+ customer_phone: Optional[str] = None
+ status: str
+ opened_at: datetime
+ closed_at: Optional[datetime] = None
+ closed_by_id: Optional[int] = None
+ notes: Optional[str] = None
+ balance: float = 0.0 # computed: sum(entries) - sum(payments)
+ total_charged: float = 0.0
+ total_paid: float = 0.0
+ entries: List[TabEntryOut] = []
+ payments: List[TabPaymentOut] = []
+
+ model_config = {"from_attributes": True}
+
+
+class TabPayRequest(BaseModel):
+ amount: float
+ payment_method: Optional[str] = None
+ notes: Optional[str] = None
+
+
+class TabForgiveRequest(BaseModel):
+ reason: Optional[str] = None
+
+
+class TabCloseRequest(BaseModel):
+ notes: Optional[str] = None
diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx
index bd4046b..2e8ea8a 100644
--- a/manager_dashboard/src/App.jsx
+++ b/manager_dashboard/src/App.jsx
@@ -15,6 +15,7 @@ import NotesPage from './pages/NotesPage'
import ContactsPage from './pages/ContactsPage'
import ExpensesPage from './pages/ExpensesPage'
import CustomersPage from './pages/CustomersPage'
+import TabsPage from './pages/TabsPage'
import client from './api/client'
function Spinner() {
@@ -89,6 +90,7 @@ export default function App() {
+ {openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '} + 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)} +
+