Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s

- New module: Municipalidad de Belén receipt extraction via pdftotext+regex
  - Backend: MunicipalReceipt + WaterMeterReading models, upload/list/detail/water-consumption endpoints
  - Auto-creates budget Transaction on upload (duplicate-safe via reference)
  - Frontend: ServiciosMunicipales page with summary cards, water consumption bar chart, receipt history, PDF upload
- Convert top navbar to left sidebar with section headers (General, Finanzas, Servicios)
  - Desktop: fixed 220px sidebar, mobile: sheet overlay
  - Grouped nav: Dashboard | Presupuesto, Salarios, Pensiones, Analytics | Municipalidad

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-02 16:11:51 -06:00
parent 45166f9d20
commit 739a32efd4
8 changed files with 1492 additions and 84 deletions

View File

@@ -0,0 +1,285 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
Category,
Currency,
MunicipalReceipt,
MunicipalReceiptRead,
Transaction,
TransactionSource,
TransactionType,
WaterMeterReading,
WaterMeterReadingRead,
)
from app.services.municipal_receipt_pdf import extract_municipal_receipt
router = APIRouter(prefix="/municipal-receipts", tags=["municipal-receipts"])
# --- Response models ---
class MunicipalReceiptDetailRead(MunicipalReceiptRead):
water_readings: list[WaterMeterReadingRead] = []
class MunicipalReceiptUploadResult(BaseModel):
imported: int
updated: int
errors: list[str]
receipt: Optional[MunicipalReceiptRead] = None
# --- Helpers ---
def _auto_categorize(merchant: str, session: Session) -> Optional[int]:
categories = session.exec(select(Category)).all()
merchant_lower = merchant.lower()
for cat in categories:
if cat.auto_match_patterns:
patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")]
if any(p in merchant_lower for p in patterns if p):
return cat.id
return None
def _upsert_receipt(
session: Session, data: dict, filename: str
) -> tuple[MunicipalReceipt, bool]:
"""Insert or update a municipal receipt. Returns (row, is_new)."""
r = data["receipt"]
totals = data["totals"]
receipt_date_str = r["date"]
# The receipt is issued in month N but covers month N-1
receipt_dt = datetime.strptime(receipt_date_str, "%Y-%m-%d").date()
billing_month = receipt_dt - relativedelta(months=1)
period = billing_month.strftime("%Y-%m")
existing = session.exec(
select(MunicipalReceipt).where(
MunicipalReceipt.account == r["account"],
MunicipalReceipt.period == period,
)
).first()
charges = [
{"detail": c["detail"], "amount": c.get("amount", 0)}
for c in data.get("charges", [])
]
fields = dict(
receipt_date=datetime.strptime(receipt_date_str, "%Y-%m-%d").date(),
due_date=datetime.strptime(r["due_date"], "%Y-%m-%d").date(),
period=period,
account=r["account"],
finca=r.get("finca", ""),
holder_name=r.get("account_holder", {}).get("name", ""),
holder_cedula=r.get("account_holder", {}).get("cedula", ""),
holder_address=r.get("account_holder", {}).get("address", ""),
subtotal=totals.get("subtotal", 0),
interests=totals.get("interests", 0),
iva=totals.get("iva", 0),
total=totals.get("total", 0),
raw_charges=charges,
source_filename=filename,
)
if existing:
for k, v in fields.items():
setattr(existing, k, v)
session.add(existing)
# Delete old water readings for this receipt
old_readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == existing.id
)
).all()
for rd in old_readings:
session.delete(rd)
session.flush()
return existing, False
row = MunicipalReceipt(**fields)
session.add(row)
session.flush()
return row, True
def _insert_water_readings(
session: Session, receipt: MunicipalReceipt, data: dict
) -> None:
"""Insert water meter readings (current + historical) for a receipt."""
# Current period readings
for wm in data.get("water_meters", []):
reading = WaterMeterReading(
receipt_id=receipt.id,
meter_id=str(wm["meter_id"]),
period=wm["period"],
reading_previous=wm.get("reading_previous", 0),
reading_current=wm.get("reading_current", 0),
consumption_m3=wm.get("consumption_m3", 0),
agua_potable=wm.get("agua_potable", 0),
serv_ambientales=wm.get("serv_ambientales", 0),
alcant_sanitario=wm.get("alcant_sanitario", 0),
iva=wm.get("iva", 0),
is_historical=False,
)
session.add(reading)
# Historical consumption entries
for hc in data.get("historical_consumption", []):
period = hc["period"]
meter_id = str(hc["meter_id"])
# Upsert: check if this historical entry already exists
existing = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.meter_id == meter_id,
WaterMeterReading.period == period,
WaterMeterReading.is_historical == True, # noqa: E712
)
).first()
if existing:
existing.consumption_m3 = hc.get("consumption_m3", 0)
session.add(existing)
else:
session.add(
WaterMeterReading(
receipt_id=receipt.id,
meter_id=meter_id,
period=period,
consumption_m3=hc.get("consumption_m3", 0),
is_historical=True,
)
)
def _ensure_transaction(
session: Session, receipt: MunicipalReceipt
) -> None:
"""Create a budget Transaction for this receipt if one doesn't exist."""
reference = f"municipal-{receipt.account}-{receipt.period}"
existing = session.exec(
select(Transaction).where(Transaction.reference == reference)
).first()
if existing:
# Update amount in case receipt was re-uploaded with corrections
existing.amount = receipt.total
session.add(existing)
return
category_id = _auto_categorize("municipalidad", session)
tx = Transaction(
amount=receipt.total,
currency=Currency.CRC,
merchant="Municipalidad de Belén",
date=datetime.combine(receipt.receipt_date, datetime.min.time()),
transaction_type=TransactionType.COMPRA,
source=TransactionSource.TRANSFER,
reference=reference,
category_id=category_id,
notes=f"Recibo municipal {receipt.period}",
)
session.add(tx)
# --- Endpoints ---
@router.post("/upload", response_model=MunicipalReceiptUploadResult)
async def upload_municipal_receipt(
file: UploadFile,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
filename = file.filename or "unknown.pdf"
errors: list[str] = []
try:
pdf_bytes = await file.read()
data = extract_municipal_receipt(pdf_bytes, filename)
except ValueError as e:
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
except Exception as e:
return MunicipalReceiptUploadResult(
imported=0, updated=0, errors=[f"{filename}: {e}"]
)
receipt, is_new = _upsert_receipt(session, data, filename)
_insert_water_readings(session, receipt, data)
_ensure_transaction(session, receipt)
session.commit()
session.refresh(receipt)
return MunicipalReceiptUploadResult(
imported=1 if is_new else 0,
updated=0 if is_new else 1,
errors=errors,
receipt=MunicipalReceiptRead.model_validate(receipt),
)
@router.get("/", response_model=list[MunicipalReceiptRead])
def list_receipts(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(MunicipalReceipt).order_by(
MunicipalReceipt.receipt_date.desc() # type: ignore[union-attr]
)
).all()
return rows
@router.get("/water-consumption", response_model=list[WaterMeterReadingRead])
def get_water_consumption(
months: int = Query(default=24, ge=1, le=120),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(WaterMeterReading)
.where(WaterMeterReading.is_historical == False) # noqa: E712
.order_by(
WaterMeterReading.period.asc(), # type: ignore[union-attr]
WaterMeterReading.meter_id.asc(), # type: ignore[union-attr]
)
.limit(months * 3) # up to 3 meters per month
).all()
return rows
@router.get("/{receipt_id}", response_model=MunicipalReceiptDetailRead)
def get_receipt_detail(
receipt_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
receipt = session.get(MunicipalReceipt, receipt_id)
if not receipt:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Receipt not found")
readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == receipt_id
)
).all()
return MunicipalReceiptDetailRead(
**MunicipalReceiptRead.model_validate(receipt).model_dump(),
water_readings=[
WaterMeterReadingRead.model_validate(r) for r in readings
],
)

View File

@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
categories,
exchange_rate,
import_transactions,
municipal_receipts,
notifications,
pensions,
salarios,
@@ -30,3 +31,4 @@ api_router.include_router(budget.router)
api_router.include_router(notifications.router)
api_router.include_router(salarios.router)
api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router)

View File

@@ -358,3 +358,75 @@ class BalanceOverrideRead(SQLModel):
month: int
override_balance: float
updated_at: datetime
# --- Municipal Receipt ---
class MunicipalReceiptBase(SQLModel):
receipt_date: date
due_date: date
period: str # "YYYY-MM"
account: str
finca: str
holder_name: str
holder_cedula: str
holder_address: str
subtotal: float
interests: float
iva: float
total: float
raw_charges: list[dict] = Field(
default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"),
)
source_filename: str
class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt",
)
class MunicipalReceiptCreate(MunicipalReceiptBase):
pass
class MunicipalReceiptRead(MunicipalReceiptBase):
id: int
created_at: datetime
# --- Water Meter Reading ---
class WaterMeterReadingBase(SQLModel):
meter_id: str
period: str # "YYYY-MM"
reading_previous: float = 0
reading_current: float = 0
consumption_m3: float
agua_potable: float = 0
serv_ambientales: float = 0
alcant_sanitario: float = 0
iva: float = 0
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings",
)
class WaterMeterReadingRead(WaterMeterReadingBase):
id: int
created_at: datetime

View File

@@ -0,0 +1,291 @@
"""
Extract structured data from Municipalidad de Belén receipts using pdftotext + regex.
"""
import re
import subprocess
import tempfile
from dataclasses import dataclass, field
def _parse_amount(s: str) -> float:
"""Parse a Costa Rican formatted number: '1,875.00' → 1875.00"""
return float(s.replace(",", ""))
def _parse_date(s: str) -> str:
"""Convert dd/mm/yyyy → YYYY-MM-DD"""
d, m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
def _parse_period(s: str) -> str:
"""Convert mm/yyyy → YYYY-MM"""
m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}"
@dataclass
class Charge:
detail: str
interests: float
iva: float
amount: float
@dataclass
class WaterMeter:
period: str
meter_id: str
reading_previous: int
reading_current: int
consumption_m3: int
agua_potable: float
serv_ambientales: float
alcant_sanitario: float
iva: float
@dataclass
class HistoricalConsumption:
meter_id: str
period: str
consumption_m3: int
@dataclass
class MunicipalReceiptData:
receipt_date: str # YYYY-MM-DD
due_date: str # YYYY-MM-DD
holder_name: str
holder_cedula: str
holder_address: str
account: str
finca: str
charges: list[Charge] = field(default_factory=list)
subtotal: float = 0.0
interests: float = 0.0
iva: float = 0.0
total: float = 0.0
water_meters: list[WaterMeter] = field(default_factory=list)
historical_consumption: list[HistoricalConsumption] = field(default_factory=list)
def _pdf_to_text(pdf_bytes: bytes) -> str:
"""Convert PDF bytes to text using pdftotext -layout."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
tmp.write(pdf_bytes)
tmp.flush()
result = subprocess.run(
["pdftotext", "-layout", tmp.name, "-"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise ValueError(f"pdftotext failed: {result.stderr}")
return result.stdout
# Regex patterns
RE_FECHA = re.compile(r"Fecha:\s*(\d{2}/\d{2}/\d{4})")
RE_VENCIMIENTO = re.compile(r"Fecha de vencimiento:\s*(\d{2}/\d{2}/\d{4})")
RE_NOMBRE = re.compile(r"Nombre:\s*(.+)")
RE_CEDULA = re.compile(r"Cédula:\s*(\d+)")
RE_DIRECCION = re.compile(r"Dirección:\s*(.+)")
# Charge line: DETAIL_TEXT account finca interests iva periodo_actual periodo_anterior
RE_CHARGE = re.compile(
r"^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s.]+?)\s+"
r"(\d{4})\s+"
r"(\d{6}---\d{3})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s*$"
)
RE_SUBTOTAL = re.compile(r"Sub-Total:\s+([\d,]+\.\d{2})")
RE_INTERESES = re.compile(r"Intereses:\s+([\d,]+\.\d{2})")
RE_IVA = re.compile(r"IVA\s+([\d,]+\.\d{2})")
RE_TOTAL = re.compile(r"Total:\s+([\d,]+\.\d{2})")
# Water meter line: period meter_id lec_ant lec_act consumo agua_potable serv_amb alcant iva
RE_WATER_METER = re.compile(
r"(\d{2}/\d{4})\s+"
r"(\d{4})\s+"
r"(\d{5})\s+"
r"(\d{5})\s+"
r"(\d+)\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})"
)
# Historical consumption: meter_id period consumption
RE_HISTORICAL = re.compile(
r"(\d{4})\s+(\d{2}/\d{4})\s+(\d{5})"
)
def extract_municipal_receipt(
pdf_bytes: bytes, filename: str
) -> dict:
"""Extract structured data from a municipal receipt PDF.
Returns a dict matching the target JSON schema.
"""
text = _pdf_to_text(pdf_bytes)
if "RECIBO MUNICIPAL" not in text:
raise ValueError(f"{filename}: Not a municipal receipt")
data = MunicipalReceiptData(
receipt_date="",
due_date="",
holder_name="",
holder_cedula="",
holder_address="",
account="",
finca="",
)
# --- Header fields ---
m = RE_FECHA.search(text)
if m:
data.receipt_date = _parse_date(m.group(1))
m = RE_VENCIMIENTO.search(text)
if m:
data.due_date = _parse_date(m.group(1))
m = RE_NOMBRE.search(text)
if m:
data.holder_name = m.group(1).strip()
m = RE_CEDULA.search(text)
if m:
data.holder_cedula = m.group(1).strip()
m = RE_DIRECCION.search(text)
if m:
data.holder_address = m.group(1).strip().rstrip(".")
# --- Charges ---
for line in text.splitlines():
m = RE_CHARGE.match(line.strip())
if m:
detail = m.group(1).strip()
data.account = m.group(2)
data.finca = m.group(3)
interests = _parse_amount(m.group(4))
iva = _parse_amount(m.group(5))
amount = _parse_amount(m.group(6))
data.charges.append(Charge(detail=detail, interests=interests, iva=iva, amount=amount))
# --- Totals ---
m = RE_SUBTOTAL.search(text)
if m:
data.subtotal = _parse_amount(m.group(1))
m = RE_INTERESES.search(text)
if m:
data.interests = _parse_amount(m.group(1))
m = RE_IVA.search(text)
if m:
data.iva = _parse_amount(m.group(1))
m = RE_TOTAL.search(text)
if m:
data.total = _parse_amount(m.group(1))
# --- Water meters ---
for m in RE_WATER_METER.finditer(text):
data.water_meters.append(
WaterMeter(
period=_parse_period(m.group(1)),
meter_id=m.group(2),
reading_previous=int(m.group(3)),
reading_current=int(m.group(4)),
consumption_m3=int(m.group(5)),
agua_potable=_parse_amount(m.group(6)),
serv_ambientales=_parse_amount(m.group(7)),
alcant_sanitario=_parse_amount(m.group(8)),
iva=_parse_amount(m.group(9)),
)
)
# --- Historical consumption ---
# Only parse lines AFTER "DETALLE DE CONSUMO MESES ANTERIORES"
hist_section = text.split("DETALLE DE CONSUMO MESES ANTERIORES")
if len(hist_section) > 1:
for m in RE_HISTORICAL.finditer(hist_section[1]):
data.historical_consumption.append(
HistoricalConsumption(
meter_id=m.group(1),
period=_parse_period(m.group(2)),
consumption_m3=int(m.group(3)),
)
)
# --- Validation ---
if not data.receipt_date:
raise ValueError(f"{filename}: Could not parse receipt date")
if not data.charges:
raise ValueError(f"{filename}: No charges found")
# --- Build output dict ---
return {
"receipt": {
"type": "RECIBO MUNICIPAL",
"issuer": {
"name": "MUNICIPALIDAD DE BELÉN",
"phone": "(506) 2587-0000",
"fax": "(506) 2293-3667",
"website": "www.belen.go.cr",
},
"date": data.receipt_date,
"due_date": data.due_date,
"account_holder": {
"name": data.holder_name,
"cedula": data.holder_cedula,
"address": data.holder_address,
},
"account": data.account,
"finca": data.finca,
},
"charges": [
{"detail": c.detail, "interests": c.interests, "iva": c.iva, "amount": c.amount}
for c in data.charges
],
"totals": {
"subtotal": data.subtotal,
"interests": data.interests,
"iva": data.iva,
"total": data.total,
},
"water_meters": [
{
"period": wm.period,
"meter_id": wm.meter_id,
"reading_previous": wm.reading_previous,
"reading_current": wm.reading_current,
"consumption_m3": wm.consumption_m3,
"agua_potable": wm.agua_potable,
"serv_ambientales": wm.serv_ambientales,
"alcant_sanitario": wm.alcant_sanitario,
"iva": wm.iva,
}
for wm in data.water_meters
],
"historical_consumption": [
{
"meter_id": hc.meter_id,
"period": hc.period,
"consumption_m3": hc.consumption_m3,
}
for hc in data.historical_consumption
],
}