forecasting/backend/api/reconciliation.py
jtricerolph 75d2c1fa9d Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 18:49:34 +00:00

1281 lines
43 KiB
Python

"""
Reconciliation API Endpoints
Provides cash-up management, Newbook payment integration, multi-day reporting,
float management, attachment handling, and reconciliation settings.
"""
import os
import shutil
import logging
from datetime import date, datetime
from typing import Optional, List
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db
from auth import get_current_user, get_admin_user
from services.reconciliation_service import (
categorize_payments,
calculate_payment_totals,
parse_till_transactions,
build_reconciliation_rows,
build_multi_day_report,
build_transaction_breakdown,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _parse_date(d: str) -> date:
"""Convert YYYY-MM-DD string to datetime.date for asyncpg compatibility."""
return date.fromisoformat(d)
UPLOAD_DIR = "/app/uploads/reconciliation"
# ============================================
# PYDANTIC MODELS
# ============================================
class DenominationEntry(BaseModel):
count_type: str # 'float' | 'takings'
denomination_type: str # 'note' | 'coin'
denomination_value: float
quantity: Optional[int] = None
value_entered: Optional[float] = None
total_amount: float
class CardMachineEntry(BaseModel):
machine_name: str
total_amount: float
amex_amount: float
visa_mc_amount: float
class ReconciliationEntry(BaseModel):
category: str
banked_amount: float
reported_amount: float
variance: float
class CashUpCreate(BaseModel):
session_date: str # YYYY-MM-DD
class CashUpUpdate(BaseModel):
denominations: List[DenominationEntry] = []
card_machines: List[CardMachineEntry] = []
reconciliation: List[ReconciliationEntry] = []
notes: Optional[str] = None
total_float_counted: float = 0.0
total_cash_counted: float = 0.0
class FloatDenominationEntry(BaseModel):
denomination_value: float
quantity: int
total_amount: float
class FloatReceiptEntry(BaseModel):
receipt_value: float
receipt_description: Optional[str] = None
class FloatCountCreate(BaseModel):
count_type: str # 'petty_cash' | 'change_tin' | 'safe_cash'
count_date: Optional[str] = None
denominations: List[FloatDenominationEntry] = []
receipts: List[FloatReceiptEntry] = []
total_counted: float = 0.0
total_receipts: float = 0.0
target_amount: float = 0.0
variance: float = 0.0
notes: Optional[str] = None
class ReconSettingsUpdate(BaseModel):
expected_till_float: Optional[float] = None
variance_threshold: Optional[float] = None
default_report_days: Optional[int] = None
petty_cash_target: Optional[float] = None
change_tin_breakdown: Optional[dict] = None
safe_cash_target: Optional[float] = None
sales_breakdown_columns: Optional[list] = None
denominations: Optional[dict] = None
class BulkFinalizeRequest(BaseModel):
ids: List[int]
# ============================================
# CASH UP CRUD
# ============================================
@router.get("/cash-ups")
async def list_cash_ups(
status: Optional[str] = Query(None, description="Filter by status (draft/final)"),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""List cash-ups with filters and pagination."""
conditions = []
params = {}
if status:
conditions.append("c.status = :status")
params["status"] = status
if date_from:
conditions.append("c.session_date >= :date_from")
params["date_from"] = _parse_date(date_from)
if date_to:
conditions.append("c.session_date <= :date_to")
params["date_to"] = _parse_date(date_to)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
offset = (page - 1) * per_page
params["limit"] = per_page
params["offset"] = offset
# Get count
count_result = await db.execute(
text(f"SELECT COUNT(*) FROM recon_cash_ups c {where}"), params
)
total = count_result.scalar()
# Get rows with variance data from reconciliation table
result = await db.execute(
text(f"""
SELECT c.*, u.display_name as created_by_name,
su.display_name as submitted_by_name,
COALESCE(SUM(r.variance), 0) as total_variance,
COALESCE(SUM(CASE WHEN r.category = 'Cash' THEN r.variance ELSE 0 END), 0) as cash_variance,
COALESCE(SUM(CASE WHEN r.category IN ('PDQ Visa/MC', 'PDQ Amex', 'Gateway Visa/MC', 'Gateway Amex') THEN r.variance ELSE 0 END), 0) as card_variance,
COALESCE(SUM(CASE WHEN r.category = 'BACS' THEN r.variance ELSE 0 END), 0) as bacs_variance
FROM recon_cash_ups c
LEFT JOIN users u ON c.created_by = u.id
LEFT JOIN users su ON c.submitted_by = su.id
LEFT JOIN recon_reconciliation r ON c.id = r.cash_up_id
{where}
GROUP BY c.id, u.display_name, su.display_name
ORDER BY c.session_date DESC
LIMIT :limit OFFSET :offset
"""), params
)
rows = result.fetchall()
cash_ups = []
for row in rows:
cash_ups.append({
"id": row.id,
"session_date": row.session_date.isoformat() if row.session_date else None,
"status": row.status,
"total_float_counted": float(row.total_float_counted or 0),
"total_cash_counted": float(row.total_cash_counted or 0),
"notes": row.notes,
"created_by": row.created_by,
"created_by_name": row.created_by_name,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"submitted_at": row.submitted_at.isoformat() if row.submitted_at else None,
"submitted_by_name": row.submitted_by_name,
"total_variance": float(row.total_variance or 0),
"cash_variance": float(row.cash_variance or 0),
"card_variance": float(row.card_variance or 0),
"bacs_variance": float(row.bacs_variance or 0),
})
return {
"cash_ups": cash_ups,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": (total + per_page - 1) // per_page
}
@router.get("/cash-ups/by-date/{session_date}")
async def get_cash_up_by_date(
session_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Check if cash-up exists for a given date and return full data."""
result = await db.execute(
text("SELECT * FROM recon_cash_ups WHERE session_date = :d"),
{"d": _parse_date(session_date)}
)
cash_up = result.fetchone()
if not cash_up:
raise HTTPException(status_code=404, detail="No cash-up found for this date")
return await _build_full_cash_up(db, cash_up)
@router.get("/cash-ups/{cash_up_id}")
async def get_cash_up(
cash_up_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get full cash-up with all related data."""
result = await db.execute(
text("SELECT * FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
cash_up = result.fetchone()
if not cash_up:
raise HTTPException(status_code=404, detail="Cash-up not found")
return await _build_full_cash_up(db, cash_up)
async def _build_full_cash_up(db: AsyncSession, cash_up) -> dict:
"""Build full cash-up response with denominations, cards, reconciliation, attachments."""
cash_up_id = cash_up.id
# Denominations
denom_result = await db.execute(
text("SELECT * FROM recon_denominations WHERE cash_up_id = :id ORDER BY count_type, denomination_value DESC"),
{"id": cash_up_id}
)
denominations = [
{
"id": d.id,
"count_type": d.count_type,
"denomination_type": d.denomination_type,
"denomination_value": float(d.denomination_value),
"quantity": d.quantity,
"value_entered": float(d.value_entered) if d.value_entered else None,
"total_amount": float(d.total_amount),
}
for d in denom_result.fetchall()
]
# Card machines
card_result = await db.execute(
text("SELECT * FROM recon_card_machines WHERE cash_up_id = :id"),
{"id": cash_up_id}
)
card_machines = [
{
"id": c.id,
"machine_name": c.machine_name,
"total_amount": float(c.total_amount),
"amex_amount": float(c.amex_amount),
"visa_mc_amount": float(c.visa_mc_amount),
}
for c in card_result.fetchall()
]
# Reconciliation rows
recon_result = await db.execute(
text("SELECT * FROM recon_reconciliation WHERE cash_up_id = :id"),
{"id": cash_up_id}
)
reconciliation = [
{
"id": r.id,
"category": r.category,
"banked_amount": float(r.banked_amount),
"reported_amount": float(r.reported_amount),
"variance": float(r.variance),
}
for r in recon_result.fetchall()
]
# Attachments
attach_result = await db.execute(
text("SELECT * FROM recon_attachments WHERE cash_up_id = :id ORDER BY uploaded_at DESC"),
{"id": cash_up_id}
)
attachments = [
{
"id": a.id,
"file_name": a.file_name,
"file_type": a.file_type,
"file_size": a.file_size,
"uploaded_at": a.uploaded_at.isoformat() if a.uploaded_at else None,
}
for a in attach_result.fetchall()
]
# Creator name
user_result = await db.execute(
text("SELECT display_name FROM users WHERE id = :id"),
{"id": cash_up.created_by}
)
creator = user_result.fetchone()
return {
"cash_up": {
"id": cash_up.id,
"session_date": cash_up.session_date.isoformat() if cash_up.session_date else None,
"status": cash_up.status,
"total_float_counted": float(cash_up.total_float_counted or 0),
"total_cash_counted": float(cash_up.total_cash_counted or 0),
"notes": cash_up.notes,
"created_by": cash_up.created_by,
"created_by_name": creator.display_name if creator else None,
"created_at": cash_up.created_at.isoformat() if cash_up.created_at else None,
"updated_at": cash_up.updated_at.isoformat() if cash_up.updated_at else None,
"submitted_at": cash_up.submitted_at.isoformat() if cash_up.submitted_at else None,
},
"denominations": denominations,
"card_machines": card_machines,
"reconciliation": reconciliation,
"attachments": attachments,
}
@router.post("/cash-ups")
async def create_cash_up(
data: CashUpCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Create a new cash-up session."""
# Check if date already exists
parsed_date = _parse_date(data.session_date)
existing = await db.execute(
text("SELECT id FROM recon_cash_ups WHERE session_date = :d"),
{"d": parsed_date}
)
if existing.fetchone():
raise HTTPException(status_code=409, detail="Cash-up already exists for this date")
result = await db.execute(
text("""
INSERT INTO recon_cash_ups (session_date, created_by, status, created_at, updated_at)
VALUES (:session_date, :created_by, 'draft', NOW(), NOW())
RETURNING id, session_date, status, created_at
"""),
{"session_date": parsed_date, "created_by": current_user["id"]}
)
await db.commit()
row = result.fetchone()
return {
"id": row.id,
"session_date": row.session_date.isoformat(),
"status": row.status,
"created_at": row.created_at.isoformat(),
}
@router.put("/cash-ups/{cash_up_id}")
async def update_cash_up(
cash_up_id: int,
data: CashUpUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update a cash-up (denominations, cards, reconciliation, notes)."""
# Verify exists and is draft
existing = await db.execute(
text("SELECT id, status FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
cash_up = existing.fetchone()
if not cash_up:
raise HTTPException(status_code=404, detail="Cash-up not found")
if cash_up.status == 'final':
raise HTTPException(status_code=400, detail="Cannot edit a finalized cash-up")
# Update main record
await db.execute(
text("""
UPDATE recon_cash_ups
SET total_float_counted = :float_total,
total_cash_counted = :cash_total,
notes = :notes,
updated_at = NOW()
WHERE id = :id
"""),
{
"id": cash_up_id,
"float_total": data.total_float_counted,
"cash_total": data.total_cash_counted,
"notes": data.notes,
}
)
# Replace denominations
await db.execute(
text("DELETE FROM recon_denominations WHERE cash_up_id = :id"),
{"id": cash_up_id}
)
for d in data.denominations:
await db.execute(
text("""
INSERT INTO recon_denominations
(cash_up_id, count_type, denomination_type, denomination_value, quantity, value_entered, total_amount)
VALUES (:cid, :ct, :dt, :dv, :q, :ve, :ta)
"""),
{
"cid": cash_up_id,
"ct": d.count_type,
"dt": d.denomination_type,
"dv": d.denomination_value,
"q": d.quantity,
"ve": d.value_entered,
"ta": d.total_amount,
}
)
# Replace card machines
await db.execute(
text("DELETE FROM recon_card_machines WHERE cash_up_id = :id"),
{"id": cash_up_id}
)
for c in data.card_machines:
await db.execute(
text("""
INSERT INTO recon_card_machines
(cash_up_id, machine_name, total_amount, amex_amount, visa_mc_amount)
VALUES (:cid, :mn, :ta, :aa, :vma)
"""),
{
"cid": cash_up_id,
"mn": c.machine_name,
"ta": c.total_amount,
"aa": c.amex_amount,
"vma": c.visa_mc_amount,
}
)
# Replace reconciliation rows
await db.execute(
text("DELETE FROM recon_reconciliation WHERE cash_up_id = :id"),
{"id": cash_up_id}
)
for r in data.reconciliation:
await db.execute(
text("""
INSERT INTO recon_reconciliation
(cash_up_id, category, banked_amount, reported_amount, variance)
VALUES (:cid, :cat, :ba, :ra, :var)
"""),
{
"cid": cash_up_id,
"cat": r.category,
"ba": r.banked_amount,
"ra": r.reported_amount,
"var": r.variance,
}
)
await db.commit()
return {"message": "Cash-up updated successfully", "id": cash_up_id}
@router.post("/cash-ups/{cash_up_id}/finalize")
async def finalize_cash_up(
cash_up_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Finalize a draft cash-up."""
existing = await db.execute(
text("SELECT id, status FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
cash_up = existing.fetchone()
if not cash_up:
raise HTTPException(status_code=404, detail="Cash-up not found")
if cash_up.status == 'final':
raise HTTPException(status_code=400, detail="Cash-up is already finalized")
await db.execute(
text("""
UPDATE recon_cash_ups
SET status = 'final', submitted_at = NOW(), submitted_by = :user_id, updated_at = NOW()
WHERE id = :id
"""),
{"id": cash_up_id, "user_id": current_user["id"]}
)
await db.commit()
return {"message": "Cash-up finalized", "id": cash_up_id}
@router.delete("/cash-ups/{cash_up_id}")
async def delete_cash_up(
cash_up_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete a draft cash-up. Admins can delete any; staff only drafts they created."""
existing = await db.execute(
text("SELECT id, status, created_by FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
cash_up = existing.fetchone()
if not cash_up:
raise HTTPException(status_code=404, detail="Cash-up not found")
if cash_up.status == 'final' and current_user.get("role") != "admin":
raise HTTPException(status_code=400, detail="Cannot delete a finalized cash-up")
# Delete attachments from filesystem
attach_result = await db.execute(
text("SELECT file_path FROM recon_attachments WHERE cash_up_id = :id"),
{"id": cash_up_id}
)
for a in attach_result.fetchall():
if os.path.exists(a.file_path):
os.remove(a.file_path)
# CASCADE handles child records
await db.execute(
text("DELETE FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
await db.commit()
return {"message": "Cash-up deleted"}
@router.post("/cash-ups/bulk-finalize")
async def bulk_finalize_cash_ups(
data: BulkFinalizeRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_admin_user)
):
"""Finalize multiple cash-ups (admin only)."""
finalized = 0
errors = []
for cash_up_id in data.ids:
result = await db.execute(
text("SELECT id, status FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
row = result.fetchone()
if not row:
errors.append(f"ID {cash_up_id}: not found")
elif row.status == 'final':
errors.append(f"ID {cash_up_id}: already finalized")
else:
await db.execute(
text("""
UPDATE recon_cash_ups
SET status = 'final', submitted_at = NOW(), submitted_by = :user_id, updated_at = NOW()
WHERE id = :id
"""),
{"id": cash_up_id, "user_id": current_user["id"]}
)
finalized += 1
await db.commit()
return {"finalized": finalized, "errors": errors}
# ============================================
# NEWBOOK PAYMENT INTEGRATION
# ============================================
@router.get("/newbook/payments/{payment_date}")
async def fetch_newbook_payments(
payment_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Fetch and categorize payments from Newbook for a given date."""
from services.newbook_client import NewbookClient
try:
target_date = date.fromisoformat(payment_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
try:
async with await NewbookClient.from_db(db) as client:
raw_transactions = await client.get_transaction_flow(target_date, target_date)
except Exception as e:
logger.error(f"Newbook API error: {e}")
raise HTTPException(status_code=502, detail=f"Newbook API error: {str(e)}")
# Process transactions
payments = categorize_payments(raw_transactions)
totals = calculate_payment_totals(payments)
till_breakdown = parse_till_transactions(raw_transactions)
transaction_breakdown = build_transaction_breakdown(payments)
# Store payment records (replace existing for this date)
await db.execute(
text("DELETE FROM recon_payment_records WHERE payment_date::date = :d"),
{"d": target_date}
)
for p in payments:
# Parse payment_date string to datetime for asyncpg
pd_str = p['payment_date']
try:
pd_val = datetime.fromisoformat(pd_str) if pd_str else datetime.now()
except (ValueError, TypeError):
pd_val = datetime.now()
await db.execute(
text("""
INSERT INTO recon_payment_records
(newbook_payment_id, booking_id, guest_name, payment_date,
payment_type, payment_method, transaction_method, card_type,
amount, tendered, processed_by, item_type, synced_at)
VALUES (:pid, :bid, :gn, :pd, :pt, :pm, :tm, :ct, :amt, :ten, :pb, :it, NOW())
"""),
{
"pid": p['payment_id'],
"bid": p['booking_id'],
"gn": p['guest_name'],
"pd": pd_val,
"pt": p['payment_type'],
"pm": p['payment_method'],
"tm": p['transaction_method'],
"ct": p['card_type'],
"amt": p['amount'],
"ten": p['tendered'],
"pb": p['processed_by'],
"it": p['item_type'],
}
)
await db.commit()
return {
"date": payment_date,
"payment_count": len(payments),
"totals": totals,
"till_breakdown": till_breakdown,
"transaction_breakdown": transaction_breakdown,
"payments": payments,
}
@router.get("/newbook/daily-stats/{stats_date}")
async def fetch_newbook_daily_stats(
stats_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Fetch daily stats (occupancy, sales, debtors/creditors) from existing app data."""
try:
target_date = date.fromisoformat(stats_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
# Fetch from existing bookings stats
stats_result = await db.execute(
text("""
SELECT booking_count as rooms_sold, guests_count as total_people,
guest_rate_total as gross_sales
FROM newbook_bookings_stats
WHERE date = :d
"""),
{"d": target_date}
)
stats_row = stats_result.fetchone()
stats = {
"business_date": stats_date,
"rooms_sold": stats_row.rooms_sold if stats_row else 0,
"total_people": stats_row.total_people if stats_row else 0,
"gross_sales": float(stats_row.gross_sales) if stats_row else 0,
"debtors_creditors_balance": 0,
}
# Upsert into recon_daily_stats
await db.execute(
text("""
INSERT INTO recon_daily_stats (business_date, gross_sales, rooms_sold, total_people, source, updated_at)
VALUES (:d, :gs, :rs, :tp, 'newbook_auto', NOW())
ON CONFLICT (business_date)
DO UPDATE SET gross_sales = :gs, rooms_sold = :rs, total_people = :tp,
source = 'newbook_auto', updated_at = NOW()
"""),
{"d": target_date, "gs": stats["gross_sales"], "rs": stats["rooms_sold"], "tp": stats["total_people"]}
)
await db.commit()
return stats
# ============================================
# MULTI-DAY REPORT
# ============================================
@router.get("/reports/multi-day")
async def generate_multi_day_report(
start_date: str = Query(...),
num_days: int = Query(7, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Generate multi-day reconciliation report with 3 tables."""
from datetime import timedelta
try:
start = date.fromisoformat(start_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
end = start + timedelta(days=num_days - 1)
# Fetch cash-ups for range
cu_result = await db.execute(
text("""
SELECT c.* FROM recon_cash_ups c
WHERE c.session_date BETWEEN :start AND :end
ORDER BY c.session_date ASC
"""),
{"start": start, "end": end}
)
cash_up_rows = cu_result.fetchall()
# Build cash-up dicts with card machines
cash_ups = []
for cu in cash_up_rows:
cards_result = await db.execute(
text("SELECT * FROM recon_card_machines WHERE cash_up_id = :id"),
{"id": cu.id}
)
card_machines = [
{"machine_name": c.machine_name, "total_amount": float(c.total_amount),
"amex_amount": float(c.amex_amount), "visa_mc_amount": float(c.visa_mc_amount)}
for c in cards_result.fetchall()
]
cash_ups.append({
"session_date": cu.session_date.isoformat(),
"status": cu.status,
"total_float_counted": float(cu.total_float_counted or 0),
"total_cash_counted": float(cu.total_cash_counted or 0),
"card_machines": card_machines,
})
# Fetch stored payment totals by date from recon_payment_records
payment_totals_by_date = {}
pr_result = await db.execute(
text("""
SELECT payment_date::date as pdate, card_type, transaction_method,
SUM(amount) as total
FROM recon_payment_records
WHERE payment_date::date BETWEEN :start AND :end
GROUP BY payment_date::date, card_type, transaction_method
"""),
{"start": start, "end": end}
)
for row in pr_result.fetchall():
d = row.pdate.isoformat()
if d not in payment_totals_by_date:
payment_totals_by_date[d] = {
'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0,
'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0
}
card_type = row.card_type or ''
tm = (row.transaction_method or '').lower()
amount = float(row.total or 0)
if card_type == 'cash':
payment_totals_by_date[d]['cash'] += amount
elif card_type == 'bacs':
payment_totals_by_date[d]['bacs'] += amount
elif tm == 'manual':
if card_type == 'amex':
payment_totals_by_date[d]['manual_amex'] += amount
elif card_type == 'visa_mc':
payment_totals_by_date[d]['manual_visa_mc'] += amount
elif tm in ('automated', 'gateway', 'cc_gateway'):
if card_type == 'amex':
payment_totals_by_date[d]['gateway_amex'] += amount
elif card_type == 'visa_mc':
payment_totals_by_date[d]['gateway_visa_mc'] += amount
# Fetch daily stats
ds_result = await db.execute(
text("""
SELECT * FROM recon_daily_stats
WHERE business_date BETWEEN :start AND :end
ORDER BY business_date ASC
"""),
{"start": start, "end": end}
)
daily_stats = [
{
"business_date": s.business_date.isoformat(),
"gross_sales": float(s.gross_sales or 0),
"rooms_sold": s.rooms_sold or 0,
"total_people": s.total_people or 0,
"debtors_creditors_balance": float(s.debtors_creditors_balance or 0),
}
for s in ds_result.fetchall()
]
# Fetch sales breakdown
sb_result = await db.execute(
text("""
SELECT * FROM recon_sales_breakdown
WHERE business_date BETWEEN :start AND :end
ORDER BY business_date ASC, category ASC
"""),
{"start": start, "end": end}
)
sales_breakdown = [
{
"business_date": s.business_date.isoformat(),
"category": s.category,
"net_amount": float(s.net_amount or 0),
}
for s in sb_result.fetchall()
]
report = build_multi_day_report(cash_ups, payment_totals_by_date, daily_stats, sales_breakdown)
return report
# ============================================
# FLOAT COUNTS (Petty Cash, Change Tin, Safe Cash)
# ============================================
@router.get("/float-counts")
async def list_float_counts(
count_type: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""List float counts with filters."""
conditions = []
params = {}
if count_type:
conditions.append("f.count_type = :count_type")
params["count_type"] = count_type
if date_from:
conditions.append("f.count_date >= :date_from")
params["date_from"] = datetime.fromisoformat(date_from)
if date_to:
conditions.append("f.count_date <= :date_to")
params["date_to"] = datetime.fromisoformat(date_to + "T23:59:59")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
offset = (page - 1) * per_page
params["limit"] = per_page
params["offset"] = offset
count_result = await db.execute(
text(f"SELECT COUNT(*) FROM recon_float_counts f {where}"), params
)
total = count_result.scalar()
result = await db.execute(
text(f"""
SELECT f.*, u.display_name as created_by_name
FROM recon_float_counts f
LEFT JOIN users u ON f.created_by = u.id
{where}
ORDER BY f.count_date DESC
LIMIT :limit OFFSET :offset
"""), params
)
rows = result.fetchall()
float_counts = []
for row in rows:
float_counts.append({
"id": row.id,
"count_type": row.count_type,
"count_date": row.count_date.isoformat() if row.count_date else None,
"total_counted": float(row.total_counted or 0),
"total_receipts": float(row.total_receipts or 0),
"target_amount": float(row.target_amount or 0),
"variance": float(row.variance or 0),
"notes": row.notes,
"created_by_name": row.created_by_name,
"created_at": row.created_at.isoformat() if row.created_at else None,
})
return {"float_counts": float_counts, "total": total, "page": page, "per_page": per_page}
@router.get("/float-counts/{count_id}")
async def get_float_count(
count_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get float count with denominations and receipts."""
result = await db.execute(
text("SELECT f.*, u.display_name as created_by_name FROM recon_float_counts f LEFT JOIN users u ON f.created_by = u.id WHERE f.id = :id"),
{"id": count_id}
)
fc = result.fetchone()
if not fc:
raise HTTPException(status_code=404, detail="Float count not found")
denom_result = await db.execute(
text("SELECT * FROM recon_float_denominations WHERE float_count_id = :id ORDER BY denomination_value DESC"),
{"id": count_id}
)
denominations = [
{"denomination_value": float(d.denomination_value), "quantity": d.quantity, "total_amount": float(d.total_amount)}
for d in denom_result.fetchall()
]
receipt_result = await db.execute(
text("SELECT * FROM recon_float_receipts WHERE float_count_id = :id"),
{"id": count_id}
)
receipts = [
{"id": r.id, "receipt_value": float(r.receipt_value), "receipt_description": r.receipt_description}
for r in receipt_result.fetchall()
]
return {
"float_count": {
"id": fc.id,
"count_type": fc.count_type,
"count_date": fc.count_date.isoformat() if fc.count_date else None,
"total_counted": float(fc.total_counted or 0),
"total_receipts": float(fc.total_receipts or 0),
"target_amount": float(fc.target_amount or 0),
"variance": float(fc.variance or 0),
"notes": fc.notes,
"created_by_name": fc.created_by_name,
},
"denominations": denominations,
"receipts": receipts,
}
@router.post("/float-counts")
async def create_float_count(
data: FloatCountCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Create a new float count."""
count_date = datetime.fromisoformat(data.count_date) if data.count_date else datetime.now()
result = await db.execute(
text("""
INSERT INTO recon_float_counts
(count_type, count_date, created_by, total_counted, total_receipts, target_amount, variance, notes)
VALUES (:ct, :cd, :cb, :tc, :tr, :ta, :var, :notes)
RETURNING id
"""),
{
"ct": data.count_type,
"cd": count_date,
"cb": current_user["id"],
"tc": data.total_counted,
"tr": data.total_receipts,
"ta": data.target_amount,
"var": data.variance,
"notes": data.notes,
}
)
fc_id = result.fetchone().id
# Insert denominations
for d in data.denominations:
await db.execute(
text("""
INSERT INTO recon_float_denominations (float_count_id, denomination_value, quantity, total_amount)
VALUES (:fid, :dv, :q, :ta)
"""),
{"fid": fc_id, "dv": d.denomination_value, "q": d.quantity, "ta": d.total_amount}
)
# Insert receipts
for r in data.receipts:
await db.execute(
text("""
INSERT INTO recon_float_receipts (float_count_id, receipt_value, receipt_description)
VALUES (:fid, :rv, :rd)
"""),
{"fid": fc_id, "rv": r.receipt_value, "rd": r.receipt_description}
)
await db.commit()
return {"id": fc_id, "message": "Float count saved"}
@router.put("/float-counts/{count_id}")
async def update_float_count(
count_id: int,
data: FloatCountCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update a float count."""
existing = await db.execute(
text("SELECT id FROM recon_float_counts WHERE id = :id"),
{"id": count_id}
)
if not existing.fetchone():
raise HTTPException(status_code=404, detail="Float count not found")
await db.execute(
text("""
UPDATE recon_float_counts
SET total_counted = :tc, total_receipts = :tr, target_amount = :ta,
variance = :var, notes = :notes
WHERE id = :id
"""),
{
"id": count_id,
"tc": data.total_counted,
"tr": data.total_receipts,
"ta": data.target_amount,
"var": data.variance,
"notes": data.notes,
}
)
# Replace denominations
await db.execute(text("DELETE FROM recon_float_denominations WHERE float_count_id = :id"), {"id": count_id})
for d in data.denominations:
await db.execute(
text("INSERT INTO recon_float_denominations (float_count_id, denomination_value, quantity, total_amount) VALUES (:fid, :dv, :q, :ta)"),
{"fid": count_id, "dv": d.denomination_value, "q": d.quantity, "ta": d.total_amount}
)
# Replace receipts
await db.execute(text("DELETE FROM recon_float_receipts WHERE float_count_id = :id"), {"id": count_id})
for r in data.receipts:
await db.execute(
text("INSERT INTO recon_float_receipts (float_count_id, receipt_value, receipt_description) VALUES (:fid, :rv, :rd)"),
{"fid": count_id, "rv": r.receipt_value, "rd": r.receipt_description}
)
await db.commit()
return {"message": "Float count updated", "id": count_id}
@router.delete("/float-counts/{count_id}")
async def delete_float_count(
count_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete a float count."""
existing = await db.execute(
text("SELECT id FROM recon_float_counts WHERE id = :id"),
{"id": count_id}
)
if not existing.fetchone():
raise HTTPException(status_code=404, detail="Float count not found")
await db.execute(text("DELETE FROM recon_float_counts WHERE id = :id"), {"id": count_id})
await db.commit()
return {"message": "Float count deleted"}
# ============================================
# ATTACHMENTS
# ============================================
@router.post("/cash-ups/{cash_up_id}/attachments")
async def upload_attachment(
cash_up_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Upload an attachment to a cash-up."""
# Verify cash-up exists
existing = await db.execute(
text("SELECT id FROM recon_cash_ups WHERE id = :id"),
{"id": cash_up_id}
)
if not existing.fetchone():
raise HTTPException(status_code=404, detail="Cash-up not found")
# Validate file type
allowed_types = ['image/jpeg', 'image/png', 'application/pdf']
if file.content_type not in allowed_types:
raise HTTPException(status_code=400, detail=f"File type not allowed. Use JPEG, PNG, or PDF.")
# Read file and check size (5MB max)
contents = await file.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large. Maximum 5MB.")
# Save file
os.makedirs(UPLOAD_DIR, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"{cash_up_id}_{timestamp}_{file.filename}"
file_path = os.path.join(UPLOAD_DIR, safe_name)
with open(file_path, "wb") as f:
f.write(contents)
# Insert record
result = await db.execute(
text("""
INSERT INTO recon_attachments (cash_up_id, file_name, file_path, file_type, file_size, uploaded_by)
VALUES (:cid, :fn, :fp, :ft, :fs, :ub)
RETURNING id, file_name, uploaded_at
"""),
{
"cid": cash_up_id,
"fn": file.filename,
"fp": file_path,
"ft": file.content_type,
"fs": len(contents),
"ub": current_user["id"],
}
)
await db.commit()
row = result.fetchone()
return {
"id": row.id,
"file_name": row.file_name,
"uploaded_at": row.uploaded_at.isoformat() if row.uploaded_at else None,
}
@router.delete("/attachments/{attachment_id}")
async def delete_attachment(
attachment_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete an attachment."""
result = await db.execute(
text("SELECT id, file_path FROM recon_attachments WHERE id = :id"),
{"id": attachment_id}
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Attachment not found")
# Delete file
if os.path.exists(row.file_path):
os.remove(row.file_path)
await db.execute(text("DELETE FROM recon_attachments WHERE id = :id"), {"id": attachment_id})
await db.commit()
return {"message": "Attachment deleted"}
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(
attachment_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Download an attachment file."""
result = await db.execute(
text("SELECT file_name, file_path, file_type FROM recon_attachments WHERE id = :id"),
{"id": attachment_id}
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Attachment not found")
if not os.path.exists(row.file_path):
raise HTTPException(status_code=404, detail="File not found on disk")
return FileResponse(
path=row.file_path,
filename=row.file_name,
media_type=row.file_type
)
# ============================================
# RECONCILIATION SETTINGS
# ============================================
@router.get("/settings")
async def get_recon_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get all reconciliation settings from system_config."""
result = await db.execute(
text("SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'recon_%'")
)
rows = result.fetchall()
settings = {}
for row in rows:
key = row.config_key.replace('recon_', '', 1)
value = row.config_value
# Try to parse JSON values
if value and value.startswith('{') or value and value.startswith('['):
import json
try:
value = json.loads(value)
except (json.JSONDecodeError, TypeError):
pass
settings[key] = value
return settings
@router.post("/settings")
async def update_recon_settings(
data: ReconSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_admin_user)
):
"""Update reconciliation settings (admin only)."""
import json
updates = {}
if data.expected_till_float is not None:
updates['recon_expected_till_float'] = str(data.expected_till_float)
if data.variance_threshold is not None:
updates['recon_variance_threshold'] = str(data.variance_threshold)
if data.default_report_days is not None:
updates['recon_default_report_days'] = str(data.default_report_days)
if data.petty_cash_target is not None:
updates['recon_petty_cash_target'] = str(data.petty_cash_target)
if data.change_tin_breakdown is not None:
updates['recon_change_tin_breakdown'] = json.dumps(data.change_tin_breakdown)
if data.safe_cash_target is not None:
updates['recon_safe_cash_target'] = str(data.safe_cash_target)
if data.sales_breakdown_columns is not None:
updates['recon_sales_breakdown_columns'] = json.dumps(data.sales_breakdown_columns)
if data.denominations is not None:
updates['recon_denominations'] = json.dumps(data.denominations)
for key, value in updates.items():
await db.execute(
text("""
UPDATE system_config SET config_value = :val, updated_at = NOW(), updated_by = :user
WHERE config_key = :key
"""),
{"key": key, "val": value, "user": current_user["username"]}
)
await db.commit()
return {"message": "Settings updated", "updated_keys": list(updates.keys())}
@router.post("/settings/refresh-gl-accounts")
async def refresh_gl_accounts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_admin_user)
):
"""Fetch GL accounts from Newbook and return for column configuration."""
from services.newbook_client import NewbookClient
try:
async with await NewbookClient.from_db(db) as client:
gl_accounts = await client.get_gl_account_list()
except Exception as e:
logger.error(f"Failed to fetch GL accounts: {e}")
raise HTTPException(status_code=502, detail=f"Newbook API error: {str(e)}")
return {"gl_accounts": gl_accounts}