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:
@@ -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"])
|
||||
|
||||
58
local_backend/models/tabs.py
Normal file
58
local_backend/models/tabs.py
Normal file
@@ -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])
|
||||
@@ -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}
|
||||
|
||||
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]
|
||||
63
local_backend/schemas/tabs.py
Normal file
63
local_backend/schemas/tabs.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user