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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user