""" API endpoints for credit note management. Handles: - Credit note upload - Credit note download - Credit note CRUD operations """ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from sqlalchemy.orm import selectinload from datetime import date as date_type from typing import Optional from pydantic import BaseModel from decimal import Decimal from auth import get_current_user, require_cap from database import get_db from models.user import User from models.dispute import CreditNote, InvoiceDispute, DisputeStatus, DisputeActivity from models.invoice import Invoice from models.supplier import Supplier from services.dispute_archival_service import DisputeArchivalService from services.upload_validation import read_and_validate_upload router = APIRouter() # Pydantic Schemas class CreditNoteResponse(BaseModel): id: int invoice_id: int supplier_id: int supplier_name: str credit_note_number: str credit_date: str credit_amount: float reason: Optional[str] notes: Optional[str] file_storage_location: str created_at: str created_by: str class Config: from_attributes = True # Endpoints @router.post("/upload") async def upload_credit_note( file: UploadFile = File(...), invoice_id: int = Form(...), credit_note_number: str = Form(...), credit_date: str = Form(...), credit_amount: float = Form(...), dispute_id: Optional[int] = Form(None), reason: Optional[str] = Form(None), notes: Optional[str] = Form(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Upload credit note PDF""" # Verify invoice exists and belongs to kitchen result = await db.execute( select(Invoice).where( and_( Invoice.id == invoice_id, Invoice.kitchen_id == current_user.kitchen_id ) ) ) invoice = result.scalar_one_or_none() if not invoice: raise HTTPException(status_code=404, detail="Invoice not found") # Parse credit date try: credit_date_obj = date_type.fromisoformat(credit_date) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") # Read + validate file content (sniffed, not the client header — A5) file_content = await read_and_validate_upload(file, {"application/pdf"}) # Save file archival_service = DisputeArchivalService(db, current_user.kitchen_id) success, file_path = await archival_service.save_credit_note( invoice, file_content, file.filename or "credit_note.pdf" ) if not success: raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}") # Create credit note record credit_note = CreditNote( kitchen_id=current_user.kitchen_id, invoice_id=invoice_id, supplier_id=invoice.supplier_id or 0, # Use 0 if no supplier (will need to handle) credit_note_number=credit_note_number, credit_date=credit_date_obj, credit_amount=Decimal(str(credit_amount)), reason=reason, notes=notes, file_path=file_path, file_storage_location="local", created_by=current_user.id ) db.add(credit_note) await db.flush() # Get credit_note.id # If linked to dispute, update dispute if dispute_id: 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 dispute: # Link credit note to dispute dispute.credit_note_id = credit_note.id dispute.resolved_amount = credit_note.credit_amount # Update dispute status if still open/in_progress if dispute.status in [DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS]: dispute.status = DisputeStatus.AWAITING_CREDIT # Log activity activity = DisputeActivity( dispute_id=dispute_id, activity_type="credit_note_added", description=f"Credit note {credit_note_number} (£{credit_amount:.2f}) linked to dispute", created_by=current_user.id ) db.add(activity) await db.commit() await db.refresh(credit_note) # Archive to Nextcloud if enabled success, result = await archival_service.archive_credit_note(credit_note) if success: await db.commit() # Update archived status return { "id": credit_note.id, "credit_note_number": credit_note_number, "credit_amount": credit_amount, "archived": success } @router.get("/{credit_note_id}") async def get_credit_note( credit_note_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> CreditNoteResponse: """Get credit note details""" result = await db.execute( select(CreditNote).options( selectinload(CreditNote.supplier), selectinload(CreditNote.created_by_user) ).where( and_( CreditNote.id == credit_note_id, CreditNote.kitchen_id == current_user.kitchen_id ) ) ) credit_note = result.scalar_one_or_none() if not credit_note: raise HTTPException(status_code=404, detail="Credit note not found") return CreditNoteResponse( id=credit_note.id, invoice_id=credit_note.invoice_id, supplier_id=credit_note.supplier_id, supplier_name=credit_note.supplier.name if credit_note.supplier else "Unknown", credit_note_number=credit_note.credit_note_number, credit_date=credit_note.credit_date.isoformat(), credit_amount=float(credit_note.credit_amount), reason=credit_note.reason, notes=credit_note.notes, file_storage_location=credit_note.file_storage_location, created_at=credit_note.created_at.isoformat(), created_by=credit_note.created_by_user.name if credit_note.created_by_user else "Unknown" ) @router.get("/{credit_note_id}/download") async def download_credit_note( credit_note_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Download credit note PDF""" result = await db.execute( select(CreditNote).where( and_( CreditNote.id == credit_note_id, CreditNote.kitchen_id == current_user.kitchen_id ) ) ) credit_note = result.scalar_one_or_none() if not credit_note: raise HTTPException(status_code=404, detail="Credit note not found") # Get file content archival_service = DisputeArchivalService(db, current_user.kitchen_id) success, content = await archival_service.get_credit_note_content(credit_note) if not success: raise HTTPException(status_code=404, detail="File not found") return Response( content=content, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{credit_note.credit_note_number}.pdf"'} ) @router.patch("/{credit_note_id}") async def update_credit_note( credit_note_id: int, credit_note_number: Optional[str] = None, credit_date: Optional[str] = None, credit_amount: Optional[float] = None, reason: Optional[str] = None, notes: Optional[str] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update credit note details""" result = await db.execute( select(CreditNote).where( and_( CreditNote.id == credit_note_id, CreditNote.kitchen_id == current_user.kitchen_id ) ) ) credit_note = result.scalar_one_or_none() if not credit_note: raise HTTPException(status_code=404, detail="Credit note not found") if credit_note_number: credit_note.credit_note_number = credit_note_number if credit_date: try: credit_note.credit_date = date_type.fromisoformat(credit_date) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") if credit_amount is not None: credit_note.credit_amount = Decimal(str(credit_amount)) if reason is not None: credit_note.reason = reason if notes is not None: credit_note.notes = notes await db.commit() return {"status": "updated"} @router.delete("/{credit_note_id}") async def delete_credit_note( credit_note_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Delete credit note""" result = await db.execute( select(CreditNote).where( and_( CreditNote.id == credit_note_id, CreditNote.kitchen_id == current_user.kitchen_id ) ) ) credit_note = result.scalar_one_or_none() if not credit_note: raise HTTPException(status_code=404, detail="Credit note not found") # Check if linked to dispute result = await db.execute( select(InvoiceDispute).where(InvoiceDispute.credit_note_id == credit_note_id) ) dispute = result.scalar_one_or_none() if dispute: # Unlink from dispute dispute.credit_note_id = None dispute.resolved_amount = None await db.delete(credit_note) await db.commit() return {"status": "deleted"}