refactor: Add JobSummary and Transaction models, client to get currency exchange rate

This commit is contained in:
2026-06-29 20:57:06 +05:30
parent 2e556173d4
commit f3727af697
14 changed files with 518 additions and 150 deletions

View File

@@ -4,10 +4,15 @@ from datetime import datetime
from typing import Any, Dict, List
from app.core.exceptions import InvalidCSVException
REQUIRED_COLUMNS = {"transaction_id", "date", "amount", "category", "description"}
REQUIRED_COLUMNS = {"txn_id", "date", "merchant", "amount"}
def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
"""
Parses CSV content from bytes, validates headers, and checks field types.
Supports flexible mapping of common synonyms for headers.
Returns a list of dicts. Raises InvalidCSVException if the file is invalid.
"""
try:
decoded = content.decode("utf-8")
except UnicodeDecodeError as e:
@@ -16,32 +21,66 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
csv_file = io.StringIO(decoded)
reader = csv.DictReader(csv_file)
# Map headers to predefined required cols
if not reader.fieldnames:
raise InvalidCSVException("CSV file is empty or missing headers")
header_mapping = {}
for actual_header in reader.fieldnames:
if not actual_header:
continue
cleaned = actual_header.strip().lower()
if cleaned in REQUIRED_COLUMNS:
header_mapping[cleaned] = actual_header
elif cleaned == "id" and "transaction_id" not in header_mapping:
header_mapping["transaction_id"] = actual_header
# Map synonyms
if cleaned in {"txn_id", "transaction_id", "id"}:
header_mapping["txn_id"] = actual_header
elif cleaned == "date":
header_mapping["date"] = actual_header
elif cleaned in {"merchant", "description"}:
header_mapping["merchant"] = actual_header
elif cleaned == "amount":
header_mapping["amount"] = actual_header
elif cleaned == "currency":
header_mapping["currency"] = actual_header
elif cleaned == "category":
header_mapping["category"] = actual_header
elif cleaned in {"account_id", "account"}:
header_mapping["account_id"] = actual_header
# Verify all required columns are present in mapping
missing = REQUIRED_COLUMNS - set(header_mapping.keys())
if missing:
raise InvalidCSVException(f"Missing required CSV columns: {', '.join(missing)}")
parsed_rows = []
for line_num, row in enumerate(reader, start=2):
t_id = row.get(header_mapping["transaction_id"])
txn_id = row.get(header_mapping["txn_id"])
date_str = row.get(header_mapping["date"])
amount_str = row.get(header_mapping["amount"])
category = row.get(header_mapping["category"])
description = row.get(header_mapping["description"])
merchant = row.get(header_mapping["merchant"])
if not all([t_id, date_str, amount_str, category, description]):
# Optional columns
currency = (
row.get(header_mapping.get("currency", ""))
if "currency" in header_mapping
else "INR"
)
category = (
row.get(header_mapping.get("category", ""))
if "category" in header_mapping
else None
)
account_id = (
row.get(header_mapping.get("account_id", ""))
if "account_id" in header_mapping
else None
)
if not all([txn_id, date_str, amount_str, merchant]):
raise InvalidCSVException(
f"Row {line_num}: Empty values are not allowed in required fields"
)
# Validate amount
try:
amount = float(amount_str.strip())
except ValueError as e:
@@ -49,6 +88,7 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
f"Row {line_num}: Invalid amount '{amount_str}'. Must be a number."
) from e
# Validate date (supporting multiple formats)
parsed_date = None
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%d-%m-%Y", "%Y/%m/%d"):
try:
@@ -64,11 +104,13 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
parsed_rows.append(
{
"transaction_id": t_id.strip(),
"txn_id": txn_id.strip(),
"date": parsed_date.isoformat(),
"amount": amount,
"category": category.strip(),
"description": description.strip(),
"merchant": merchant.strip(),
"currency": currency.strip() if currency else "INR",
"category": category.strip() if category else None,
"account_id": account_id.strip() if account_id else None,
}
)