kitchen/docs/archive/PLAN_7_invoice_dispute_tracking.md
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

36 KiB

Plan 7: Invoice Dispute Tracking System

Current State

Existing Invoice Management

Files:

  • backend/models/invoices.py - Invoice database model
  • backend/api/invoices.py - Invoice CRUD endpoints
  • frontend/src/pages/Invoices.tsx - Invoice list view

Current invoice fields:

  • invoice_number, supplier_id, invoice_date
  • total_amount, tax_amount
  • status (pending, approved, processed)
  • Line items with products and quantities

What's Missing:

  • No dispute/issue tracking
  • No price dispute logging
  • No shortage documentation
  • No error correction workflow
  • No credit note linkage

Problem Statement

User Request:

"log disputed invoices for tracking price queries, shorts, errors, resolution status, dashboard widget for it"

Real-World Scenarios:

  1. Price Discrepancy:

    • Invoice shows €3.50/kg for tomatoes
    • Contract price is €2.80/kg
    • Need to: Flag issue, email supplier, track response, link credit note
  2. Short Delivery:

    • Ordered 20kg flour, received 15kg
    • Invoice charges for 20kg
    • Need to: Document shortage, request credit, track resolution
  3. Wrong Product:

    • Ordered "Chicken Breast, Free Range"
    • Received "Chicken Breast, Standard"
    • Different price point, quality issue
  4. Quality Issue:

    • Spoiled produce delivered
    • Return required, credit needed
    • Document photos, correspondence
  5. Calculation Error:

    • Line item totals incorrect
    • Tax calculated wrong
    • Invoice total doesn't match order

Goals:

  1. Log Disputes: Record issues with invoices/line items
  2. Track Status: Open, In Progress, Resolved, Closed
  3. Document Evidence: Attach emails, photos, delivery notes
  4. Link Credit Notes: Connect disputes to resolutions
  5. Dashboard Widget: Show open disputes at a glance
  6. Reporting: Analyze dispute patterns by supplier

Architecture

Data Model

┌─────────────────────────────────────────────────────┐
│                  Invoice                            │
│  - id, invoice_number, supplier_id                  │
│  - total_amount, status                             │
└─────────────────────────────────────────────────────┘
                       │
                       │ 1:many
                       ▼
┌─────────────────────────────────────────────────────┐
│              InvoiceDispute                         │
│  - id, invoice_id, dispute_type                     │
│  - status, priority                                 │
│  - disputed_amount, expected_amount                 │
│  - description, resolution_notes                    │
│  - opened_by, opened_at                             │
│  - resolved_at, resolved_by                         │
└─────────────────────────────────────────────────────┘
           │                           │
           │ 1:many                    │ 1:many
           ▼                           ▼
┌──────────────────────┐    ┌──────────────────────┐
│ DisputeLineItem      │    │ DisputeAttachment    │
│ - line_item_id       │    │ - file_path          │
│ - quantity_ordered   │    │ - file_type          │
│ - quantity_received  │    │ - description        │
│ - price_quoted       │    │ - uploaded_at        │
│ - price_charged      │    └──────────────────────┘
└──────────────────────┘
           │
           │ many:1
           ▼
┌──────────────────────┐
│    CreditNote        │
│ - id, invoice_id     │
│ - dispute_id         │
│ - credit_amount      │
│ - credit_date        │
│ - reference_number   │
└──────────────────────┘

Dispute Workflow

┌──────────────────┐
│   Create Invoice │
│   & Review       │
└────────┬─────────┘
         │
         ▼
   ┌─────────────┐
   │ Issue Found?│
   └─────┬───────┘
         │ Yes
         ▼
┌──────────────────┐      ┌──────────────────┐
│  Log Dispute     │──────┤  Add Evidence    │
│  - Type          │      │  - Photos        │
│  - Amount        │      │  - Emails        │
│  - Description   │      │  - Delivery Note │
└────────┬─────────┘      └──────────────────┘
         │
         ▼
┌──────────────────┐
│ Contact Supplier │
│ - Email          │
│ - Phone          │
│ - Portal         │
└────────┬─────────┘
         │
         ▼
   ┌───────────────┐
   │ Supplier      │
   │ Responds?     │
   └───┬───────────┘
       │
       ├─────────Yes─────────┐
       │                     │
       ▼                     ▼
┌──────────────┐      ┌──────────────┐
│ Credit Note  │      │ Partial      │
│ Issued       │      │ Resolution   │
└──────┬───────┘      └──────┬───────┘
       │                     │
       └──────────┬──────────┘
                  │
                  ▼
           ┌──────────────┐
           │ Link Credit  │
           │ to Dispute   │
           └──────┬───────┘
                  │
                  ▼
           ┌──────────────┐
           │ Close Dispute│
           └──────────────┘

Implementation Plan

Phase 1: Database Schema

1.1 Create Dispute Models

File: backend/models/disputes.py (NEW)

from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Boolean, Text, Integer, Enum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
import enum


class DisputeType(str, enum.Enum):
    PRICE_DISCREPANCY = "price_discrepancy"
    SHORT_DELIVERY = "short_delivery"
    WRONG_PRODUCT = "wrong_product"
    QUALITY_ISSUE = "quality_issue"
    CALCULATION_ERROR = "calculation_error"
    MISSING_ITEMS = "missing_items"
    DAMAGED_GOODS = "damaged_goods"
    OTHER = "other"


class DisputeStatus(str, enum.Enum):
    OPEN = "open"
    CONTACTED = "contacted"
    IN_PROGRESS = "in_progress"
    AWAITING_CREDIT = "awaiting_credit"
    RESOLVED = "resolved"
    CLOSED = "closed"
    ESCALATED = "escalated"


class DisputePriority(str, enum.Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    URGENT = "urgent"


class InvoiceDispute(Base):
    """Disputes/issues raised against invoices"""
    __tablename__ = "invoice_disputes"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
    invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False, index=True)

    # Dispute classification
    dispute_type: Mapped[DisputeType] = mapped_column(Enum(DisputeType), nullable=False, index=True)
    status: Mapped[DisputeStatus] = mapped_column(Enum(DisputeStatus), default=DisputeStatus.OPEN, index=True)
    priority: Mapped[DisputePriority] = mapped_column(Enum(DisputePriority), default=DisputePriority.MEDIUM)

    # Financial impact
    disputed_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)  # What was charged
    expected_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)  # What should be charged
    difference_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)  # Disputed - Expected

    # Description
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    description: Mapped[str] = mapped_column(Text, nullable=False)
    resolution_notes: Mapped[str | None] = mapped_column(Text, nullable=True)

    # Supplier communication
    supplier_contacted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    supplier_response: Mapped[str | None] = mapped_column(Text, nullable=True)
    supplier_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)

    # Resolution
    credit_note_id: Mapped[int | None] = mapped_column(ForeignKey("credit_notes.id"), nullable=True)
    resolved_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)  # Actual credit received

    # Audit trail
    opened_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
    resolved_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
    resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    closed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
    closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

    # Metadata
    reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True)  # Internal tracking number
    tags: Mapped[list | None] = mapped_column(JSONB, nullable=True)  # ["urgent", "recurring_issue", etc.]

    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    # Relationships
    kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="invoice_disputes")
    invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="disputes")
    opened_by_user: Mapped["User"] = relationship("User", foreign_keys=[opened_by])
    resolved_by_user: Mapped["User"] = relationship("User", foreign_keys=[resolved_by])
    closed_by_user: Mapped["User"] = relationship("User", foreign_keys=[closed_by])
    credit_note: Mapped["CreditNote"] = relationship("CreditNote", back_populates="dispute")
    line_items: Mapped[list["DisputeLineItem"]] = relationship(
        "DisputeLineItem", back_populates="dispute", cascade="all, delete-orphan"
    )
    attachments: Mapped[list["DisputeAttachment"]] = relationship(
        "DisputeAttachment", back_populates="dispute", cascade="all, delete-orphan"
    )
    activity_log: Mapped[list["DisputeActivity"]] = relationship(
        "DisputeActivity", back_populates="dispute", cascade="all, delete-orphan"
    )


class DisputeLineItem(Base):
    """Specific line items that are disputed"""
    __tablename__ = "dispute_line_items"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
    invoice_line_item_id: Mapped[int | None] = mapped_column(ForeignKey("invoice_line_items.id"), nullable=True)

    # Product details
    product_name: Mapped[str] = mapped_column(String(255), nullable=False)
    product_code: Mapped[str | None] = mapped_column(String(100), nullable=True)

    # Quantity dispute
    quantity_ordered: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
    quantity_received: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
    quantity_difference: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)

    # Price dispute
    unit_price_quoted: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
    unit_price_charged: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
    price_difference: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)

    # Line total
    total_charged: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    total_expected: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)

    notes: Mapped[str | None] = mapped_column(Text, nullable=True)

    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

    # Relationships
    dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="line_items")
    invoice_line_item: Mapped["InvoiceLineItem"] = relationship("InvoiceLineItem")


class DisputeAttachment(Base):
    """Supporting documents for disputes (photos, emails, delivery notes)"""
    __tablename__ = "dispute_attachments"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
    kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)

    # File details
    file_name: Mapped[str] = mapped_column(String(255), nullable=False)
    file_path: Mapped[str] = mapped_column(String(500), nullable=False)
    file_type: Mapped[str] = mapped_column(String(50), nullable=False)  # image/jpeg, application/pdf, message/rfc822
    file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)

    # Categorization
    attachment_type: Mapped[str] = mapped_column(String(50), nullable=False)  # photo, email, delivery_note, credit_note, other
    description: Mapped[str | None] = mapped_column(String(500), nullable=True)

    uploaded_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

    # Relationships
    dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="attachments")
    kitchen: Mapped["Kitchen"] = relationship("Kitchen")
    uploaded_by_user: Mapped["User"] = relationship("User")


class DisputeActivity(Base):
    """Activity log for dispute tracking (timeline of actions)"""
    __tablename__ = "dispute_activity"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)

    # Activity details
    activity_type: Mapped[str] = mapped_column(String(50), nullable=False)  # created, status_change, contacted_supplier, note_added, etc.
    description: Mapped[str] = mapped_column(Text, nullable=False)

    # Old/new values for changes
    old_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
    new_value: Mapped[str | None] = mapped_column(String(255), nullable=True)

    created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)

    # Relationships
    dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="activity_log")
    created_by_user: Mapped["User"] = relationship("User")


class CreditNote(Base):
    """Credit notes issued by suppliers (resolves disputes)"""
    __tablename__ = "credit_notes"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
    invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False, index=True)
    supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=False)

    # Credit note details
    credit_note_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
    credit_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
    credit_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)

    reason: Mapped[str | None] = mapped_column(Text, nullable=True)
    notes: Mapped[str | None] = mapped_column(Text, nullable=True)

    # Document
    pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True)

    # Audit
    created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

    # Relationships
    kitchen: Mapped["Kitchen"] = relationship("Kitchen")
    invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="credit_notes")
    supplier: Mapped["Supplier"] = relationship("Supplier")
    created_by_user: Mapped["User"] = relationship("User")
    dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="credit_note", uselist=False)


# Add relationships to existing models:

# In backend/models/invoices.py - Invoice class:
# disputes: Mapped[list["InvoiceDispute"]] = relationship("InvoiceDispute", back_populates="invoice")
# credit_notes: Mapped[list["CreditNote"]] = relationship("CreditNote", back_populates="invoice")

# In backend/models/user.py - Kitchen class:
# invoice_disputes: Mapped[list["InvoiceDispute"]] = relationship("InvoiceDispute", back_populates="kitchen")

1.2 Create Migration

File: backend/migrations/add_invoice_disputes.py (NEW)

import asyncio
import logging
from sqlalchemy import text
from database import engine

logger = logging.getLogger(__name__)

async def run_migration():
    """Create invoice dispute tracking tables"""

    # Enums created by SQLAlchemy

    # Indexes
    create_indexes_sql = [
        """
        CREATE INDEX IF NOT EXISTS idx_disputes_kitchen_status
        ON invoice_disputes(kitchen_id, status, opened_at DESC);
        """,
        """
        CREATE INDEX IF NOT EXISTS idx_disputes_invoice
        ON invoice_disputes(invoice_id);
        """,
        """
        CREATE INDEX IF NOT EXISTS idx_disputes_priority
        ON invoice_disputes(kitchen_id, priority, status);
        """,
        """
        CREATE INDEX IF NOT EXISTS idx_credit_notes_invoice
        ON credit_notes(invoice_id);
        """
    ]

    try:
        async with engine.begin() as conn:
            for sql in create_indexes_sql:
                await conn.execute(text(sql))
            logger.info("Created invoice dispute indexes")
    except Exception as e:
        if "already exists" not in str(e).lower():
            raise
        logger.warning(f"Invoice disputes migration: {e}")

if __name__ == "__main__":
    asyncio.run(run_migration())

Phase 2: Backend API

2.1 Disputes API

File: backend/api/disputes.py (NEW)

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
from decimal import Decimal

from auth.jwt import get_current_user
from database import get_db
from models.user import User
from models.disputes import (
    InvoiceDispute, DisputeLineItem, DisputeAttachment, DisputeActivity,
    CreditNote, DisputeType, DisputeStatus, DisputePriority
)

router = APIRouter(prefix="/disputes", tags=["Disputes"])


# Pydantic Schemas

class DisputeLineItemInput(BaseModel):
    invoice_line_item_id: Optional[int] = None
    product_name: str
    quantity_ordered: Optional[float] = None
    quantity_received: Optional[float] = None
    unit_price_quoted: Optional[float] = None
    unit_price_charged: Optional[float] = None
    total_charged: float
    total_expected: Optional[float] = None
    notes: Optional[str] = None


class CreateDisputeInput(BaseModel):
    invoice_id: int
    dispute_type: DisputeType
    priority: DisputePriority = DisputePriority.MEDIUM
    title: str
    description: str
    disputed_amount: float
    expected_amount: Optional[float] = None
    line_items: List[DisputeLineItemInput] = []
    tags: Optional[List[str]] = None


class UpdateDisputeInput(BaseModel):
    status: Optional[DisputeStatus] = None
    priority: Optional[DisputePriority] = None
    resolution_notes: Optional[str] = None
    supplier_response: Optional[str] = None
    supplier_contact_name: Optional[str] = None


class DisputeResponse(BaseModel):
    id: int
    invoice_id: int
    invoice_number: str
    supplier_name: str
    dispute_type: str
    status: str
    priority: str
    title: str
    description: str
    disputed_amount: float
    expected_amount: Optional[float]
    difference_amount: float
    opened_at: str
    opened_by: str
    resolved_at: Optional[str]
    line_items: List[dict]
    attachments: List[dict]
    activity_log: List[dict]


# Endpoints

@router.get("")
async def get_disputes(
    status: Optional[DisputeStatus] = None,
    priority: Optional[DisputePriority] = None,
    invoice_id: Optional[int] = None,
    limit: int = 50,
    offset: int = 0,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
) -> List[DisputeResponse]:
    """Get disputes with filters"""

    query = select(InvoiceDispute).where(
        InvoiceDispute.kitchen_id == current_user.kitchen_id
    )

    if status:
        query = query.where(InvoiceDispute.status == status)

    if priority:
        query = query.where(InvoiceDispute.priority == priority)

    if invoice_id:
        query = query.where(InvoiceDispute.invoice_id == invoice_id)

    query = query.order_by(InvoiceDispute.opened_at.desc())
    query = query.limit(limit).offset(offset)

    result = await db.execute(query)
    disputes = result.scalars().all()

    return [await _format_dispute(dispute, db) for dispute in disputes]


@router.post("")
async def create_dispute(
    dispute_input: CreateDisputeInput,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
) -> DisputeResponse:
    """Create new invoice dispute"""

    # Calculate difference
    expected = Decimal(str(dispute_input.expected_amount)) if dispute_input.expected_amount else Decimal(0)
    disputed = Decimal(str(dispute_input.disputed_amount))
    difference = disputed - expected

    # Create dispute
    dispute = InvoiceDispute(
        kitchen_id=current_user.kitchen_id,
        invoice_id=dispute_input.invoice_id,
        dispute_type=dispute_input.dispute_type,
        priority=dispute_input.priority,
        status=DisputeStatus.OPEN,
        title=dispute_input.title,
        description=dispute_input.description,
        disputed_amount=disputed,
        expected_amount=expected if dispute_input.expected_amount else None,
        difference_amount=difference,
        opened_by=current_user.id,
        tags=dispute_input.tags
    )

    db.add(dispute)
    await db.flush()  # Get dispute.id

    # Add line items
    for item_input in dispute_input.line_items:
        line_item = DisputeLineItem(
            dispute_id=dispute.id,
            invoice_line_item_id=item_input.invoice_line_item_id,
            product_name=item_input.product_name,
            quantity_ordered=Decimal(str(item_input.quantity_ordered)) if item_input.quantity_ordered else None,
            quantity_received=Decimal(str(item_input.quantity_received)) if item_input.quantity_received else None,
            quantity_difference=(
                Decimal(str(item_input.quantity_ordered or 0)) - Decimal(str(item_input.quantity_received or 0))
                if item_input.quantity_ordered and item_input.quantity_received else None
            ),
            unit_price_quoted=Decimal(str(item_input.unit_price_quoted)) if item_input.unit_price_quoted else None,
            unit_price_charged=Decimal(str(item_input.unit_price_charged)) if item_input.unit_price_charged else None,
            price_difference=(
                Decimal(str(item_input.unit_price_charged or 0)) - Decimal(str(item_input.unit_price_quoted or 0))
                if item_input.unit_price_quoted and item_input.unit_price_charged else None
            ),
            total_charged=Decimal(str(item_input.total_charged)),
            total_expected=Decimal(str(item_input.total_expected)) if item_input.total_expected else None,
            notes=item_input.notes
        )
        db.add(line_item)

    # Log activity
    activity = DisputeActivity(
        dispute_id=dispute.id,
        activity_type="created",
        description=f"Dispute created: {dispute.title}",
        created_by=current_user.id
    )
    db.add(activity)

    await db.commit()
    await db.refresh(dispute)

    return await _format_dispute(dispute, db)


@router.put("/{dispute_id}")
async def update_dispute(
    dispute_id: int,
    update_input: UpdateDisputeInput,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """Update dispute status or details"""

    result = await db.execute(
        select(InvoiceDispute).where(
            and_(
                InvoiceDispute.id == dispute_id,
                InvoiceDispute.kitchen_id == current_user.kitchen_id
            )
        )
    )
    dispute = result.scalar_one_or_none()

    if not dispute:
        raise HTTPException(status_code=404, detail="Dispute not found")

    # Track changes for activity log
    changes = []

    if update_input.status and update_input.status != dispute.status:
        old_status = dispute.status.value
        dispute.status = update_input.status
        changes.append(f"Status changed from {old_status} to {update_input.status.value}")

        # Auto-set resolved_at when status changes to resolved
        if update_input.status == DisputeStatus.RESOLVED and not dispute.resolved_at:
            dispute.resolved_at = datetime.utcnow()
            dispute.resolved_by = current_user.id

    if update_input.priority and update_input.priority != dispute.priority:
        old_priority = dispute.priority.value
        dispute.priority = update_input.priority
        changes.append(f"Priority changed from {old_priority} to {update_input.priority.value}")

    if update_input.resolution_notes:
        dispute.resolution_notes = update_input.resolution_notes
        changes.append("Resolution notes updated")

    if update_input.supplier_response:
        dispute.supplier_response = update_input.supplier_response
        dispute.supplier_contacted_at = datetime.utcnow()
        changes.append("Supplier response recorded")

    if update_input.supplier_contact_name:
        dispute.supplier_contact_name = update_input.supplier_contact_name

    dispute.updated_at = datetime.utcnow()

    # Log activity
    for change in changes:
        activity = DisputeActivity(
            dispute_id=dispute.id,
            activity_type="updated",
            description=change,
            created_by=current_user.id
        )
        db.add(activity)

    await db.commit()

    return {"status": "updated", "changes": changes}


@router.post("/{dispute_id}/attachments")
async def upload_dispute_attachment(
    dispute_id: int,
    file: UploadFile = File(...),
    attachment_type: str = "other",
    description: Optional[str] = None,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """Upload supporting document (photo, email, delivery note)"""

    # Verify dispute exists
    result = await db.execute(
        select(InvoiceDispute).where(
            and_(
                InvoiceDispute.id == dispute_id,
                InvoiceDispute.kitchen_id == current_user.kitchen_id
            )
        )
    )
    dispute = result.scalar_one_or_none()
    if not dispute:
        raise HTTPException(status_code=404, detail="Dispute not found")

    # Save file
    import os
    upload_dir = f"/app/attachments/disputes/kitchen_{current_user.kitchen_id}"
    os.makedirs(upload_dir, exist_ok=True)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    file_extension = os.path.splitext(file.filename)[1]
    file_name = f"dispute_{dispute_id}_{timestamp}{file_extension}"
    file_path = f"{upload_dir}/{file_name}"

    with open(file_path, "wb") as f:
        content = await file.read()
        f.write(content)

    # Create attachment record
    attachment = DisputeAttachment(
        dispute_id=dispute_id,
        kitchen_id=current_user.kitchen_id,
        file_name=file.filename,
        file_path=file_path,
        file_type=file.content_type,
        file_size_bytes=len(content),
        attachment_type=attachment_type,
        description=description,
        uploaded_by=current_user.id
    )

    db.add(attachment)

    # Log activity
    activity = DisputeActivity(
        dispute_id=dispute_id,
        activity_type="attachment_added",
        description=f"Attachment added: {file.filename}",
        created_by=current_user.id
    )
    db.add(activity)

    await db.commit()

    return {"id": attachment.id, "file_name": file_name}


@router.get("/stats/summary")
async def get_dispute_stats(
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """Get dispute statistics for dashboard widget"""

    # Count by status
    result = await db.execute(
        select(
            InvoiceDispute.status,
            func.count(InvoiceDispute.id).label("count"),
            func.sum(InvoiceDispute.difference_amount).label("total_amount")
        ).where(
            InvoiceDispute.kitchen_id == current_user.kitchen_id
        ).group_by(InvoiceDispute.status)
    )
    status_counts = {row.status.value: {"count": row.count, "amount": float(row.total_amount or 0)} for row in result}

    # Recent disputes
    result = await db.execute(
        select(InvoiceDispute).where(
            InvoiceDispute.kitchen_id == current_user.kitchen_id
        ).order_by(InvoiceDispute.opened_at.desc()).limit(5)
    )
    recent_disputes = result.scalars().all()

    return {
        "status_counts": status_counts,
        "open_count": status_counts.get("open", {}).get("count", 0),
        "total_disputed_amount": sum(s.get("amount", 0) for s in status_counts.values()),
        "recent_disputes": [
            {
                "id": d.id,
                "title": d.title,
                "dispute_type": d.dispute_type.value,
                "amount": float(d.difference_amount),
                "opened_at": d.opened_at.isoformat()
            }
            for d in recent_disputes
        ]
    }


async def _format_dispute(dispute: InvoiceDispute, db: AsyncSession) -> DisputeResponse:
    """Format dispute for API response"""

    # Get invoice details
    from models.invoices import Invoice

    invoice = await db.get(Invoice, dispute.invoice_id)

    return DisputeResponse(
        id=dispute.id,
        invoice_id=dispute.invoice_id,
        invoice_number=invoice.invoice_number,
        supplier_name=invoice.supplier.name,
        dispute_type=dispute.dispute_type.value,
        status=dispute.status.value,
        priority=dispute.priority.value,
        title=dispute.title,
        description=dispute.description,
        disputed_amount=float(dispute.disputed_amount),
        expected_amount=float(dispute.expected_amount) if dispute.expected_amount else None,
        difference_amount=float(dispute.difference_amount),
        opened_at=dispute.opened_at.isoformat(),
        opened_by=dispute.opened_by_user.name,
        resolved_at=dispute.resolved_at.isoformat() if dispute.resolved_at else None,
        line_items=[
            {
                "id": item.id,
                "product_name": item.product_name,
                "quantity_ordered": float(item.quantity_ordered) if item.quantity_ordered else None,
                "quantity_received": float(item.quantity_received) if item.quantity_received else None,
                "total_charged": float(item.total_charged),
                "total_expected": float(item.total_expected) if item.total_expected else None
            }
            for item in dispute.line_items
        ],
        attachments=[
            {
                "id": att.id,
                "file_name": att.file_name,
                "attachment_type": att.attachment_type,
                "description": att.description
            }
            for att in dispute.attachments
        ],
        activity_log=[
            {
                "activity_type": act.activity_type,
                "description": act.description,
                "created_at": act.created_at.isoformat(),
                "created_by": act.created_by_user.name
            }
            for act in dispute.activity_log
        ]
    )

Phase 3: Frontend Implementation

3.1 Dashboard Widget

File: frontend/src/pages/Dashboard.tsx (UPDATE)

// Add disputes widget
const { data: disputeStats } = useQuery({
  queryKey: ['dispute-stats'],
  queryFn: async () => {
    const res = await fetch('/api/disputes/stats/summary', {
      headers: { Authorization: `Bearer ${token}` }
    })
    return res.json()
  }
})

// Render widget
<div style={styles.widget}>
  <h3>⚠️ Invoice Disputes</h3>
  {disputeStats && (
    <>
      <div style={styles.statRow}>
        <span>Open Disputes:</span>
        <strong style={{ color: disputeStats.open_count > 0 ? '#d97706' : 'green' }}>
          {disputeStats.open_count}
        </strong>
      </div>
      <div style={styles.statRow}>
        <span>Total Disputed Amount:</span>
        <strong style={{ color: '#dc2626' }}>
          {disputeStats.total_disputed_amount.toFixed(2)}
        </strong>
      </div>

      {disputeStats.recent_disputes.length > 0 && (
        <>
          <h4 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Recent:</h4>
          <ul style={{ fontSize: '0.85rem', paddingLeft: '1.2rem', margin: 0 }}>
            {disputeStats.recent_disputes.slice(0, 3).map((dispute) => (
              <li key={dispute.id}>
                <a href={`/disputes/${dispute.id}`}>
                  {dispute.title} ({dispute.amount.toFixed(2)})
                </a>
              </li>
            ))}
          </ul>
        </>
      )}

      <button
        onClick={() => navigate('/disputes')}
        style={styles.viewAllButton}
      >
        View All Disputes 
      </button>
    </>
  )}
</div>

3.2 Disputes List Page

File: frontend/src/pages/Disputes.tsx (NEW)

// Full disputes management page with:
// - Filterable list
// - Status badges
// - Create dispute modal
// - Bulk actions

export default function Disputes() {
  // Similar structure to Invoices page
  // Table showing: ID, Invoice #, Supplier, Type, Amount, Status, Opened Date
  // Click row to see detail modal
  // Filters: Status, Priority, Date Range, Supplier
}

3.3 Add Dispute Button on Invoice Detail

File: frontend/src/pages/InvoiceDetail.tsx (UPDATE)

Add "Log Dispute" button to invoice detail page:

<button
  onClick={() => setShowDisputeModal(true)}
  style={styles.disputeButton}
>
  ⚠️ Log Dispute
</button>

{showDisputeModal && (
  <CreateDisputeModal
    invoice={invoice}
    onClose={() => setShowDisputeModal(false)}
    onSuccess={() => {
      setShowDisputeModal(false)
      queryClient.invalidateQueries(['disputes'])
    }}
  />
)}

Success Criteria

Users can log disputes from invoice detail page Disputes tracked by type, status, priority Financial impact calculated and visible Photos, emails, delivery notes attachable Activity timeline shows full dispute history Dashboard widget shows open dispute count Credit notes linkable to disputes Disputes filterable and searchable Reports show dispute patterns by supplier

Future Enhancements

  1. Email Integration - Send dispute emails to suppliers from app
  2. Recurring Issue Detection - Flag suppliers with high dispute rates
  3. Dispute Templates - Quick dispute creation for common issues
  4. Batch Disputes - Log multiple disputes across invoices
  5. Analytics Dashboard - Dispute trends, supplier scorecards
  6. Automated Reminders - Follow-up notifications for open disputes
  7. Contract Integration - Link to price agreements for validation