BudgetBuddy / core /storage.py
KrishnaGarg's picture
Deploy BudgetBuddy update
3e16a9d verified
Raw
History Blame Contribute Delete
4.79 kB
"""Persistent per-user transaction storage on the HF Dataset.
Phase 4: each user's transactions live in their own append-only file
(data/<user>.jsonl), so one person's save never clobbers another's. A
"transaction" is the unified record for a parsed receipt, a payment screenshot,
or a manual entry — see the schema in _build_entry(). Model-independent, so it
stays unit-testable in isolation. Low-level dataset IO is in core.hubio.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from core import hubio
USER_DATA_DIR = "data"
BUDGET_DIR = "budgets"
def _num(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _norm_user(user: Any) -> str:
return str(user or "").strip().lower()
def _user_path(user: str) -> str:
return f"{USER_DATA_DIR}/{_norm_user(user)}.jsonl"
# --------------------------------------------------------------------------- #
# Build + save
# --------------------------------------------------------------------------- #
def _build_entry(record: dict[str, Any], user: str) -> dict[str, Any]:
"""Project a UI record into the persisted transaction schema (v2)."""
category = str(record.get("category") or record.get("receipt_category") or "Other")
entry: dict[str, Any] = {
"id": uuid.uuid4().hex,
"saved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"user": _norm_user(user),
"source": str(record.get("source", "receipt") or "receipt"),
"vendor": str(record.get("vendor", "") or ""),
"date": str(record.get("date", "") or ""),
"currency": str(record.get("currency", "") or ""),
"note": str(record.get("note", "") or ""),
"total": _num(record.get("total", 0)),
"category": category,
"receipt_category": category, # back-compat
"understanding": str(record.get("understanding", "") or ""),
"line_items": [
{
"name": str(it.get("name", "") or ""),
"qty": _num(it.get("qty", 1)) or 1,
"amount": _num(it.get("amount", 0)),
"category": str(it.get("category") or category),
}
for it in (record.get("line_items") or [])
],
"charges": [
{"label": str(c.get("label", "") or "Charge"), "amount": _num(c.get("amount", 0))}
for c in (record.get("charges") or [])
],
}
return entry
# --------------------------------------------------------------------------- #
# Per-user budget
# --------------------------------------------------------------------------- #
def _budget_path(user: str) -> str:
return f"{BUDGET_DIR}/{_norm_user(user)}.jsonl"
def get_budget(user: str, force: bool = False) -> float:
if not _norm_user(user):
return 0.0
rows = hubio.read_jsonl(_budget_path(user), force=force)
return _num(rows[-1].get("amount")) if rows else 0.0
def set_budget(user: str, amount: float) -> float:
if not _norm_user(user):
raise ValueError("No user.")
hubio.write_jsonl(_budget_path(user), [{"amount": _num(amount)}], "set budget")
return _num(amount)
def save(user: str, record: dict[str, Any]) -> dict[str, Any]:
"""Append a transaction to the user's file. Returns the persisted entry.
Raises on a hard failure so the UI can surface it.
"""
if not _norm_user(user):
raise ValueError("No user — sign in before saving.")
entry = _build_entry(record, user)
hubio.append_jsonl(
_user_path(user), entry, message=f"Add {entry['source']} {entry['id'][:8]}"
)
return entry
# --------------------------------------------------------------------------- #
# Load + view
# --------------------------------------------------------------------------- #
def load(user: str, force: bool = False) -> list[dict[str, Any]]:
"""All of this user's transactions, oldest first ([] if none)."""
if not _norm_user(user):
return []
return hubio.read_jsonl(_user_path(user), force=force)
def to_table(records: list[dict[str, Any]]) -> list[list[Any]]:
"""Rows for a transactions table: [date, vendor, total, category]. Recent first."""
rows: list[list[Any]] = []
for r in reversed(records):
rows.append(
[
r.get("date", ""),
r.get("vendor", ""),
_num(r.get("total", 0)),
r.get("receipt_category", "Other"),
]
)
return rows
def load_table(user: str, force: bool = False) -> list[list[Any]]:
"""Convenience: load this user's transactions, formatted for the table."""
return to_table(load(user, force=force))