Fix audit findings: budget editing, dead code, logging, multi-currency

- Add budget editing: updateBudget() API, edit button on budget cards,
  BudgetFormModal adapted for create/update (category locked on edit)
- Remove permanently-broken POST /auth/totp/verify stub and its unused
  TOTPVerifyRequest schema
- Wire getHoldingTransactions() to AssetDetail page — transaction history
  table now shows above the candlestick chart, sorted newest-first
- Fix multi-currency net worth in account_service: account balances are
  now converted to base_currency via ExchangeRate table before summing
- Replace silent bare pass exception handlers with logger.warning() in
  transactions.py (OCR/AI pipeline) and price_feed_service.py (search)
  — ValueError in date/number regex parsing left silent (control flow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 10:54:32 +00:00
parent 312594f3d2
commit 8ef3bb2965
9 changed files with 181 additions and 64 deletions

View file

@ -19,7 +19,7 @@ from app.schemas.auth import (
TOTPChallengeResponse, TOTPChallengeResponse,
TOTPLoginRequest, TOTPLoginRequest,
TOTPSetupResponse, TOTPSetupResponse,
TOTPVerifyRequest,
TokenResponse, TokenResponse,
) )
from app.services.auth_service import ( from app.services.auth_service import (
@ -297,20 +297,6 @@ async def totp_setup(
return TOTPSetupResponse(secret=secret, qr_code_png_b64=qr_b64, backup_codes=backup_codes) return TOTPSetupResponse(secret=secret, qr_code_png_b64=qr_b64, backup_codes=backup_codes)
@router.post("/totp/verify", status_code=200)
async def totp_verify(
body: TOTPVerifyRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
# Secret must be passed back from setup — here we expect it stored temporarily in body
# In practice the client stores it until verification; it's never persisted until verified
# This endpoint receives the secret + verification code
# For simplicity we accept: {"secret": "...", "code": "..."}
# Redefine body inline:
raise HTTPException(status_code=400, detail="Use /totp/enable endpoint with secret and code")
@router.post("/totp/enable", status_code=200) @router.post("/totp/enable", status_code=200)
async def totp_enable( async def totp_enable(

View file

@ -1,11 +1,14 @@
import csv import csv
import io import io
import logging
import mimetypes import mimetypes
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -265,8 +268,8 @@ async def delete_attachment(
path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"] path = Path(settings.upload_dir) / str(user.id) / ref["stored_name"]
try: try:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
except OSError: except OSError as e:
pass logger.warning("Could not delete attachment file %s: %s", path, e)
new_refs = [r for r in refs if r["id"] != attachment_id] new_refs = [r for r in refs if r["id"] != attachment_id]
await db.execute( await db.execute(
@ -307,8 +310,8 @@ def _extract_ocr_text(file_bytes: bytes, mime_type: str) -> str:
text = "\n".join(pages_text).strip() text = "\n".join(pages_text).strip()
if text: if text:
return text return text
except Exception: except Exception as e:
pass logger.warning("pdfplumber text extraction failed: %s", e)
# Scanned PDF — convert first page to image then OCR # Scanned PDF — convert first page to image then OCR
try: try:
from pdf2image import convert_from_bytes from pdf2image import convert_from_bytes
@ -316,8 +319,8 @@ def _extract_ocr_text(file_bytes: bytes, mime_type: str) -> str:
images = convert_from_bytes(file_bytes, first_page=1, last_page=1, dpi=200) images = convert_from_bytes(file_bytes, first_page=1, last_page=1, dpi=200)
if images: if images:
return pytesseract.image_to_string(images[0]) return pytesseract.image_to_string(images[0])
except Exception: except Exception as e:
pass logger.warning("pdf2image/tesseract OCR failed: %s", e)
return "" return ""
else: else:
import io import io
@ -326,7 +329,8 @@ def _extract_ocr_text(file_bytes: bytes, mime_type: str) -> str:
try: try:
img = Image.open(io.BytesIO(file_bytes)) img = Image.open(io.BytesIO(file_bytes))
return pytesseract.image_to_string(img) return pytesseract.image_to_string(img)
except Exception: except Exception as e:
logger.warning("Image OCR failed: %s", e)
return "" return ""
@ -488,11 +492,10 @@ async def _call_ai_parse(file_bytes: bytes, mime_type: str, user_row) -> dict:
"ocr_text": ocr_text, "ocr_text": ocr_text,
} }
except json.JSONDecodeError: except json.JSONDecodeError:
# AI returned something non-JSON — fall through to rules, keep raw for debug logger.warning("AI returned non-JSON response, falling back to rule-based parser")
pass
except (httpx.HTTPStatusError, httpx.RequestError): except (httpx.HTTPStatusError, httpx.RequestError) as e:
pass # fall through to rule-based logger.warning("AI API request failed (%s), falling back to rule-based parser", type(e).__name__)
# Step 3: rule-based fallback (also used when AI is not configured) # Step 3: rule-based fallback (also used when AI is not configured)
if ocr_text.strip(): if ocr_text.strip():

View file

@ -48,9 +48,6 @@ class TOTPSetupResponse(BaseModel):
backup_codes: list[str] backup_codes: list[str]
class TOTPVerifyRequest(BaseModel):
code: str
class TOTPEnableRequest(BaseModel): class TOTPEnableRequest(BaseModel):
secret: str secret: str

View file

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import encrypt_field, decrypt_field from app.core.security import encrypt_field, decrypt_field
from app.db.models.account import Account from app.db.models.account import Account
from app.db.models.currency import ExchangeRate
from app.db.models.transaction import Transaction from app.db.models.transaction import Transaction
from app.schemas.account import AccountCreate, AccountUpdate from app.schemas.account import AccountCreate, AccountUpdate
@ -168,6 +169,19 @@ async def recalculate_balance(db: AsyncSession, account_id: uuid.UUID) -> None:
await db.flush() await db.flush()
async def _fx_rate(db: AsyncSession, from_currency: str, to_currency: str) -> Decimal:
if from_currency == to_currency:
return Decimal("1")
result = await db.execute(
select(ExchangeRate)
.where(ExchangeRate.base_currency == from_currency, ExchangeRate.quote_currency == to_currency)
.order_by(ExchangeRate.fetched_at.desc())
.limit(1)
)
er = result.scalar_one_or_none()
return er.rate if er else Decimal("1")
async def get_net_worth(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> dict: async def get_net_worth(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> dict:
accounts = await db.execute( accounts = await db.execute(
select(Account).where( select(Account).where(
@ -180,8 +194,11 @@ async def get_net_worth(db: AsyncSession, user_id: uuid.UUID, base_currency: str
total_liabilities = Decimal("0") total_liabilities = Decimal("0")
for account in accounts.scalars(): for account in accounts.scalars():
# TODO Phase 3: convert to base_currency via FX rates bal = account.current_balance or Decimal("0")
bal = account.current_balance acct_currency = account.currency or base_currency
if acct_currency != base_currency:
rate = await _fx_rate(db, acct_currency, base_currency)
bal = bal * rate
if account.type in LIABILITY_TYPES: if account.type in LIABILITY_TYPES:
total_liabilities += abs(bal) total_liabilities += abs(bal)
else: else:

View file

@ -118,6 +118,6 @@ def search_yahoo(query: str) -> list[dict]:
"data_source_id": None, "data_source_id": None,
}) })
return out return out
except Exception: except Exception as exc:
pass logger.warning("yahoo_search_failed", query=query, error=str(exc))
return [] return []

View file

@ -60,6 +60,21 @@ export async function createBudget(data: BudgetCreate): Promise<Budget> {
return r.data; return r.data;
} }
export interface BudgetUpdate {
name?: string;
amount?: number;
period?: "weekly" | "monthly" | "quarterly" | "yearly";
end_date?: string | null;
rollover?: boolean;
alert_threshold?: number;
is_active?: boolean;
}
export async function updateBudget(id: string, data: BudgetUpdate): Promise<Budget> {
const r = await api.put(`/budgets/${id}`, data);
return r.data;
}
export async function deleteBudget(id: string): Promise<void> { export async function deleteBudget(id: string): Promise<void> {
await api.delete(`/budgets/${id}`); await api.delete(`/budgets/${id}`);
} }

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { BudgetCreate } from "@/api/budgets"; import type { Budget, BudgetCreate } from "@/api/budgets";
import { format } from "date-fns"; import { format } from "date-fns";
interface Category { interface Category {
@ -14,22 +14,24 @@ interface Props {
onClose: () => void; onClose: () => void;
onSubmit: (data: BudgetCreate) => void; onSubmit: (data: BudgetCreate) => void;
isLoading: boolean; isLoading: boolean;
budget?: Budget;
} }
export default function BudgetFormModal({ categories, onClose, onSubmit, isLoading }: Props) { export default function BudgetFormModal({ categories, onClose, onSubmit, isLoading, budget }: Props) {
const today = format(new Date(), "yyyy-MM-dd"); const today = format(new Date(), "yyyy-MM-dd");
const [form, setForm] = useState<BudgetCreate>({ const [form, setForm] = useState<BudgetCreate>({
category_id: "", category_id: budget?.category_id ?? "",
name: "", name: budget?.name ?? "",
amount: 0, amount: budget?.amount ?? 0,
currency: "GBP", currency: budget?.currency ?? "GBP",
period: "monthly", period: (budget?.period as BudgetCreate["period"]) ?? "monthly",
start_date: today, start_date: budget?.start_date ?? today,
end_date: null, end_date: budget?.end_date ?? null,
rollover: false, rollover: budget?.rollover ?? false,
alert_threshold: 80, alert_threshold: budget?.alert_threshold ?? 80,
}); });
const isEdit = !!budget;
const expenseCategories = categories.filter((c) => c.type === "expense" || c.type === "system"); const expenseCategories = categories.filter((c) => c.type === "expense" || c.type === "system");
function set<K extends keyof BudgetCreate>(key: K, value: BudgetCreate[K]) { function set<K extends keyof BudgetCreate>(key: K, value: BudgetCreate[K]) {
@ -46,7 +48,7 @@ export default function BudgetFormModal({ categories, onClose, onSubmit, isLoadi
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl"> <div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border"> <div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">New Budget</h2> <h2 className="font-semibold text-lg">{isEdit ? "Edit Budget" : "New Budget"}</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground"> <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@ -71,12 +73,14 @@ export default function BudgetFormModal({ categories, onClose, onSubmit, isLoadi
onChange={(e) => set("category_id", e.target.value)} onChange={(e) => set("category_id", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required required
disabled={isEdit}
> >
<option value="">Select category...</option> <option value="">Select category...</option>
{expenseCategories.map((c) => ( {expenseCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
{isEdit && <p className="text-xs text-muted-foreground mt-1">Category cannot be changed after creation.</p>}
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@ -154,7 +158,7 @@ export default function BudgetFormModal({ categories, onClose, onSubmit, isLoadi
disabled={isLoading} disabled={isLoading}
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors" className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{isLoading ? "Creating..." : "Create Budget"} {isLoading ? (isEdit ? "Saving..." : "Creating...") : (isEdit ? "Save changes" : "Create Budget")}
</button> </button>
</div> </div>
</form> </form>

View file

@ -1,10 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBudgetSummary, createBudget, deleteBudget } from "@/api/budgets"; import { getBudgets, getBudgetSummary, createBudget, updateBudget, deleteBudget, type Budget } from "@/api/budgets";
import { getCategories } from "@/api/transactions"; import { getCategories } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { Plus, Trash2, AlertTriangle, CheckCircle } from "lucide-react"; import { Plus, Trash2, Pencil, AlertTriangle, CheckCircle } from "lucide-react";
import BudgetFormModal from "./BudgetFormModal"; import BudgetFormModal from "./BudgetFormModal";
function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) { function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) {
@ -55,6 +55,7 @@ function RadialGauge({ percent, size = 80 }: { percent: number; size?: number })
export default function BudgetPage() { export default function BudgetPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Budget | null>(null);
const { data: summary = [], isLoading } = useQuery({ const { data: summary = [], isLoading } = useQuery({
queryKey: ["budget-summary"], queryKey: ["budget-summary"],
@ -62,23 +63,32 @@ export default function BudgetPage() {
refetchInterval: 60_000, refetchInterval: 60_000,
}); });
const { data: budgets = [] } = useQuery({
queryKey: ["budgets"],
queryFn: () => getBudgets(false),
});
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories }); const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
};
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createBudget, mutationFn: createBudget,
onSuccess: () => { onSuccess: () => { invalidate(); setShowForm(false); },
qc.invalidateQueries({ queryKey: ["budget-summary"] }); });
qc.invalidateQueries({ queryKey: ["budgets"] });
setShowForm(false); const updateMutation = useMutation({
}, mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateBudget>[1] }) =>
updateBudget(id, data),
onSuccess: () => { invalidate(); setEditing(null); },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteBudget, mutationFn: deleteBudget,
onSuccess: () => { onSuccess: invalidate,
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
},
}); });
const overBudget = summary.filter((s) => s.is_over_budget).length; const overBudget = summary.filter((s) => s.is_over_budget).length;
@ -133,12 +143,25 @@ export default function BudgetPage() {
: "border-border" : "border-border"
)} )}
> >
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button
onClick={() => {
const b = budgets.find((b) => b.id === item.budget_id);
if (b) setEditing(b);
}}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="Edit budget"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button <button
onClick={() => deleteMutation.mutate(item.budget_id)} onClick={() => deleteMutation.mutate(item.budget_id)}
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all" className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete budget"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<RadialGauge percent={Number(item.percent_used)} /> <RadialGauge percent={Number(item.percent_used)} />
@ -192,6 +215,16 @@ export default function BudgetPage() {
isLoading={createMutation.isPending} isLoading={createMutation.isPending}
/> />
)} )}
{editing && (
<BudgetFormModal
categories={categories}
budget={editing}
onClose={() => setEditing(null)}
onSubmit={(data) => updateMutation.mutate({ id: editing.id, data })}
isLoading={updateMutation.isPending}
/>
)}
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
import { useParams, Link } from "react-router-dom"; import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getPriceHistory, getPortfolio } from "@/api/investments"; import { getPriceHistory, getPortfolio, getHoldingTransactions } from "@/api/investments";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { useUiStore } from "@/store/uiStore"; import { useUiStore } from "@/store/uiStore";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
@ -20,6 +20,12 @@ export default function AssetDetail() {
enabled: !!assetId, enabled: !!assetId,
}); });
const { data: transactions = [] } = useQuery({
queryKey: ["holding-transactions", holding?.id],
queryFn: () => getHoldingTransactions(holding!.id),
enabled: !!holding?.id,
});
const dates = prices.map(p => p.date); const dates = prices.map(p => p.date);
const opens = prices.map(p => p.open ?? p.close); const opens = prices.map(p => p.open ?? p.close);
const highs = prices.map(p => p.high ?? p.close); const highs = prices.map(p => p.high ?? p.close);
@ -73,6 +79,62 @@ export default function AssetDetail() {
</div> </div>
)} )}
{/* Transaction history */}
{holding && (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<p className="text-sm font-medium">Transaction History</p>
</div>
{transactions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No transactions recorded</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground uppercase tracking-wider border-b border-border">
<th className="text-left px-4 py-2">Date</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-right px-4 py-2">Quantity</th>
<th className="text-right px-4 py-2">Price</th>
<th className="text-right px-4 py-2 hidden sm:table-cell">Fees</th>
<th className="text-right px-4 py-2">Total</th>
</tr>
</thead>
<tbody>
{[...transactions].sort((a, b) => b.date.localeCompare(a.date)).map((t) => {
const isBuy = t.type === "buy" || t.type === "transfer_in";
const isSell = t.type === "sell" || t.type === "transfer_out";
return (
<tr key={t.id} className="border-b border-border/50 last:border-0 hover:bg-secondary/20 transition-colors">
<td className="px-4 py-2.5 tabular-nums text-muted-foreground">{t.date}</td>
<td className="px-4 py-2.5">
<span className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
isBuy ? "bg-success/15 text-success" :
isSell ? "bg-destructive/15 text-destructive" :
"bg-secondary text-muted-foreground"
)}>
{t.type.replace("_", " ")}
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">{Number(t.quantity).toLocaleString()}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{formatCurrency(t.price, t.currency)}</td>
<td className="px-4 py-2.5 text-right tabular-nums hidden sm:table-cell text-muted-foreground">
{t.fees > 0 ? formatCurrency(t.fees, t.currency) : "—"}
</td>
<td className={cn("px-4 py-2.5 text-right tabular-nums font-medium",
isBuy ? "text-destructive" : isSell ? "text-success" : ""
)}>
{isSell ? "+" : isBuy ? "-" : ""}{formatCurrency(t.total_amount, t.currency)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
{/* Candlestick chart */} {/* Candlestick chart */}
<div className="bg-card border border-border rounded-xl p-4"> <div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Price History (1 Year)</p> <p className="text-sm font-medium mb-3">Price History (1 Year)</p>