From 883d10cac60dedaf83bd86e5430fade6c4245b4c Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Wed, 4 Feb 2026 22:24:39 +0000 Subject: [PATCH 01/10] Secure receipt images, uploads, and totals - Remove public /uploads mount; add authenticated /receipts/{id}/image endpoint (header or cookie JWT)\n- Enforce 10MB upload limit (server chunked read + client-side guard)\n- Recompute receipt totals on item create/update/delete\n- Always send Gemini image bytes as PNG; redact validation request body logging\n- Fix login redirect param handling; add tests --- backend/app/auth/deps.py | 112 +++++++++++------- backend/app/core/config.py | 6 + backend/app/core/error_handlers.py | 1 - .../integrations/pydantic_ai/receipt_agent.py | 2 +- backend/app/main.py | 5 - backend/app/receipt/router.py | 22 +++- backend/app/receipt/services.py | 70 +++++++++-- .../receipt/test_receipt_endpoints.py | 88 +++++++++++++- .../unit/receipt/test_receipt_service.py | 78 +++++++++++- frontend/src/app/(app)/receipts/[id]/page.tsx | 4 +- frontend/src/app/(auth)/login/page.tsx | 10 +- frontend/src/components/scan/dropzone.tsx | 10 ++ 12 files changed, 335 insertions(+), 73 deletions(-) diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index 3a716c5..0e9da97 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -3,7 +3,7 @@ from typing import Annotated import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlmodel.ext.asyncio.session import AsyncSession @@ -15,6 +15,53 @@ # HTTPBearer security scheme for extracting JWT tokens security = HTTPBearer() +TOKEN_COOKIE_KEY = "receipt_scanner_token" # noqa: S105 + + +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _inactive() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is inactive", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def _get_user_from_token(token: str, service: AuthService) -> User: + try: + # Decode the JWT token + payload = decode_access_token(token) + user_id_str: str | None = payload.get("sub") + + if user_id_str is None: + raise _unauthorized() + + # Convert string ID back to int + try: + user_id = int(user_id_str) + except (ValueError, TypeError): + raise _unauthorized() from None + + except jwt.InvalidTokenError as err: + raise _unauthorized() from err + + # Get the user from the database + try: + user = await service.get_user_by_id(user_id) + except NotFoundError: + raise _unauthorized() from None + + if not user.is_active: + raise _inactive() + + return user async def get_auth_service( @@ -41,60 +88,37 @@ async def get_current_user( Raises: HTTPException: 401 if token is invalid or user not found """ - token = credentials.credentials + return await _get_user_from_token(credentials.credentials, service) - try: - # Decode the JWT token - payload = decode_access_token(token) - user_id_str: str | None = payload.get("sub") - if user_id_str is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) +async def get_current_user_from_request( + request: Request, + service: AuthService = Depends(get_auth_service), +) -> User: + """Get current user from Authorization header or auth cookie.""" + token: str | None = None - # Convert string ID back to int - try: - user_id = int(user_id_str) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from None + auth_header = request.headers.get("Authorization") + if auth_header: + scheme, _, param = auth_header.partition(" ") + if scheme.lower() == "bearer" and param: + token = param + else: + token = None - except jwt.InvalidTokenError as err: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from err + if not token: + token = request.cookies.get(TOKEN_COOKIE_KEY) - # Get the user from the database - try: - user = await service.get_user_by_id(user_id) - except NotFoundError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from None + if not token: + raise _unauthorized() - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User account is inactive", - headers={"WWW-Authenticate": "Bearer"}, - ) - - return user + return await _get_user_from_token(token, service) # Type aliases for dependency injection AuthDeps = Annotated[AuthService, Depends(get_auth_service)] CurrentUser = Annotated[User, Depends(get_current_user)] +CurrentUserFromRequest = Annotated[User, Depends(get_current_user_from_request)] def require_user_id(user: User) -> int: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 699cee5..319552b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -38,6 +38,7 @@ class Settings(BaseSettings): # File upload settings UPLOAD_DIR: Path = Path("uploads") + MAX_UPLOAD_SIZE_MB: int = 10 # CORS Settings ALLOWED_ORIGINS: list[AnyHttpUrl] = [] @@ -104,6 +105,11 @@ def setup_directories(self) -> None: """Create necessary directories.""" self.UPLOAD_DIR.mkdir(exist_ok=True) + @property + def max_upload_size_bytes(self) -> int: + """Maximum upload size in bytes.""" + return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + # Create global settings instance settings = Settings() diff --git a/backend/app/core/error_handlers.py b/backend/app/core/error_handlers.py index cb909cd..55e5c5c 100644 --- a/backend/app/core/error_handlers.py +++ b/backend/app/core/error_handlers.py @@ -57,7 +57,6 @@ async def validation_exception_handler( ] # Log detailed validation errors logger.info(f"Request validation error: {error_details}") - logger.info(f"Invalid data: {exc.body}") elif isinstance(exc, PydanticValidationError): error_details = [ f"Field '{' -> '.join(str(loc) for loc in error['loc'])}' {error['msg']}" diff --git a/backend/app/integrations/pydantic_ai/receipt_agent.py b/backend/app/integrations/pydantic_ai/receipt_agent.py index b0d4f99..4a62fae 100644 --- a/backend/app/integrations/pydantic_ai/receipt_agent.py +++ b/backend/app/integrations/pydantic_ai/receipt_agent.py @@ -170,7 +170,7 @@ async def analyze_receipt( try: # Convert PIL Image to bytes img_byte_arr = BytesIO() - image.save(img_byte_arr, format=image.format or "PNG") + image.save(img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() # Create dependencies diff --git a/backend/app/main.py b/backend/app/main.py index 266015b..286d359 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,6 @@ from fastapi import FastAPI, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from fastapi.staticfiles import StaticFiles from sqlalchemy.exc import SQLAlchemyError from app import __author__ @@ -79,10 +78,6 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: app.include_router(category_router) app.include_router(analytics_router) -# Serve uploaded files (receipt images) -app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") - - # Define the root endpoint @app.get("/", include_in_schema=False) async def root() -> dict[str, str]: diff --git a/backend/app/receipt/router.py b/backend/app/receipt/router.py index 2a592b2..3c9a190 100644 --- a/backend/app/receipt/router.py +++ b/backend/app/receipt/router.py @@ -1,3 +1,4 @@ +import mimetypes from collections.abc import Sequence from datetime import UTC, datetime from decimal import Decimal @@ -5,9 +6,9 @@ from typing import Annotated from fastapi import APIRouter, File, Query, UploadFile, status -from fastapi.responses import StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse -from app.auth.deps import CurrentUser, require_user_id +from app.auth.deps import CurrentUser, CurrentUserFromRequest, require_user_id from app.receipt.deps import ReceiptDeps from app.receipt.models import ( Receipt, @@ -296,6 +297,23 @@ async def get_receipt( return await service.get(receipt_id, user_id=user_id) +@router.get( + "/{receipt_id}/image", + status_code=status.HTTP_200_OK, +) +async def get_receipt_image( + receipt_id: int, + current_user: CurrentUserFromRequest, + service: ReceiptDeps, +) -> FileResponse: + """Get a receipt image for the current user.""" + user_id = require_user_id(current_user) + receipt = await service.get(receipt_id, user_id=user_id) + image_path = service.resolve_image_path(receipt.image_path) + media_type, _ = mimetypes.guess_type(image_path.name) + return FileResponse(image_path, media_type=media_type or "application/octet-stream") + + @router.get( "/category/{category_id}/items", response_model=list[ReceiptItemRead], diff --git a/backend/app/receipt/services.py b/backend/app/receipt/services.py index 1b060f3..aa6553e 100644 --- a/backend/app/receipt/services.py +++ b/backend/app/receipt/services.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from io import StringIO +from pathlib import Path from typing import TypedDict from fastapi import UploadFile @@ -52,6 +53,32 @@ def __init__( self.session = session self.category_service = category_service + def _recalculate_total(self, receipt: Receipt) -> Decimal: + """Recalculate receipt total from item totals.""" + return sum((item.total_price for item in receipt.items), Decimal("0")) + + def resolve_image_path(self, image_path: str) -> Path: + """Resolve and validate receipt image path within upload directory.""" + upload_root = settings.UPLOAD_DIR.resolve() + upload_dir = settings.UPLOAD_DIR + path = Path(image_path) + + if path.is_absolute(): + candidate = path + elif upload_dir.parts and path.parts[: len(upload_dir.parts)] == upload_dir.parts: + candidate = (Path.cwd() / path).resolve(strict=False) + else: + candidate = (upload_root / path).resolve(strict=False) + + resolved = candidate.resolve(strict=False) + + if upload_root not in resolved.parents and resolved != upload_root: + raise NotFoundError("Receipt image not found") + if not resolved.exists() or not resolved.is_file(): + raise NotFoundError("Receipt image not found") + + return resolved + async def create(self, receipt_in: ReceiptCreate, user_id: int) -> Receipt: """Create a new receipt.""" receipt = Receipt(**receipt_in.model_dump(), user_id=user_id) @@ -81,12 +108,22 @@ async def create_from_scan(self, image_file: UploadFile, user_id: int) -> Receip unique_filename = f"{uuid.uuid4()}{file_ext}" image_path = settings.UPLOAD_DIR / unique_filename - # Save the uploaded file - with open(image_path, "wb") as f: - content = await image_file.read() - f.write(content) - try: + # Save the uploaded file with size enforcement + max_bytes = settings.max_upload_size_bytes + bytes_read = 0 + with open(image_path, "wb") as f: + while True: + chunk = await image_file.read(1024 * 1024) + if not chunk: + break + bytes_read += len(chunk) + if bytes_read > max_bytes: + raise BadRequestError( + f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE_MB}MB." + ) + f.write(chunk) + # Open and validate the image try: pil_image = Image.open(image_path) @@ -329,8 +366,12 @@ async def update_item( # Update item fields update_data = item_in.model_dump(exclude_unset=True) item.sqlmodel_update(update_data) + item.total_price = item.unit_price * item.quantity item.updated_at = datetime.now(UTC) + receipt.total_amount = self._recalculate_total(receipt) + receipt.updated_at = datetime.now(UTC) + await self.session.flush() await self.session.refresh(receipt, ["items"]) @@ -432,8 +473,13 @@ async def create_item( self.session.add(item) + await self.session.flush() + await self.session.refresh(receipt, ["items"]) + if item not in receipt.items: + receipt.items.append(item) + # Update the receipt total - receipt.total_amount = receipt.total_amount + total_price + receipt.total_amount = self._recalculate_total(receipt) receipt.updated_at = datetime.now(UTC) await self.session.flush() @@ -470,15 +516,21 @@ async def delete_item(self, receipt_id: int, item_id: int, user_id: int) -> Rece f"Item with id {item_id} not found in receipt {receipt_id}" ) - # Update the receipt total before deleting - receipt.total_amount = receipt.total_amount - item.total_price - receipt.updated_at = datetime.now(UTC) + if item in receipt.items: + receipt.items.remove(item) # Delete the item await self.session.delete(item) await self.session.flush() await self.session.refresh(receipt, ["items"]) + # Update the receipt total + receipt.total_amount = self._recalculate_total(receipt) + receipt.updated_at = datetime.now(UTC) + + await self.session.flush() + await self.session.refresh(receipt, ["items"]) + return receipt async def export_to_csv( diff --git a/backend/tests/integration/receipt/test_receipt_endpoints.py b/backend/tests/integration/receipt/test_receipt_endpoints.py index 2c213c9..675dfa2 100644 --- a/backend/tests/integration/receipt/test_receipt_endpoints.py +++ b/backend/tests/integration/receipt/test_receipt_endpoints.py @@ -1,11 +1,15 @@ """Tests for the receipt API endpoints.""" import json +from decimal import Decimal +from io import BytesIO +from pathlib import Path import pytest from fastapi.testclient import TestClient from app.category.models import Category +from app.core.config import settings from app.receipt.models import Receipt, ReceiptItem @@ -191,7 +195,6 @@ async def test_create_receipt_item( auth_headers: dict[str, str], ) -> None: """Test creating a receipt item.""" - original_total = float(test_receipt.total_amount) item_data = { "name": "New Item", "quantity": 2, @@ -216,8 +219,7 @@ async def test_create_receipt_item( assert float(data["items"][0]["total_price"]) == 11.00 # 2 * 5.50 assert data["items"][0]["category_id"] == test_category.id # Check the receipt total was updated (use approx due to floating point) - expected_total = original_total + 11.00 - assert abs(float(data["total_amount"]) - expected_total) < 0.01 + assert abs(float(data["total_amount"]) - 11.00) < 0.01 @pytest.mark.asyncio @@ -541,3 +543,83 @@ async def test_export_receipts_with_amount_filter( csv_content = response.content.decode("utf-8") # CSV should have content assert len(csv_content) > 100 # More than just header + + +@pytest.mark.asyncio +async def test_get_receipt_image_requires_auth( + test_client: TestClient, + test_session, + test_user, + test_uploads_dir: Path, + test_image: BytesIO, +) -> None: + """Test that receipt image endpoint requires authentication.""" + image_path = test_uploads_dir / "receipt_auth.png" + image_path.write_bytes(test_image.getvalue()) + + receipt = Receipt( + store_name="Image Store", + total_amount=Decimal("1.00"), + currency="$", + image_path=str(image_path), + user_id=test_user.id, + ) + test_session.add(receipt) + await test_session.commit() + await test_session.refresh(receipt) + + response = test_client.get(f"/api/v1/receipts/{receipt.id}/image") + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_receipt_image_returns_file( + test_client: TestClient, + test_session, + test_user, + auth_headers: dict[str, str], + test_uploads_dir: Path, + test_image: BytesIO, +) -> None: + """Test that receipt image endpoint returns the image for authorized users.""" + image_path = test_uploads_dir / "receipt.png" + image_bytes = test_image.getvalue() + image_path.write_bytes(image_bytes) + + receipt = Receipt( + store_name="Image Store", + total_amount=Decimal("1.00"), + currency="$", + image_path=str(image_path), + user_id=test_user.id, + ) + test_session.add(receipt) + await test_session.commit() + await test_session.refresh(receipt) + + response = test_client.get( + f"/api/v1/receipts/{receipt.id}/image", headers=auth_headers + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.content == image_bytes + + +@pytest.mark.asyncio +async def test_scan_receipt_rejects_large_upload( + test_client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Test that oversized uploads are rejected.""" + oversized = BytesIO(b"a" * (settings.max_upload_size_bytes + 1)) + + response = test_client.post( + "/api/v1/receipts/scan", + files={"image": ("large.png", oversized, "image/png")}, + headers=auth_headers, + ) + + assert response.status_code == 400 + assert "File too large" in response.json()["detail"] diff --git a/backend/tests/unit/receipt/test_receipt_service.py b/backend/tests/unit/receipt/test_receipt_service.py index d8cb658..58115ff 100644 --- a/backend/tests/unit/receipt/test_receipt_service.py +++ b/backend/tests/unit/receipt/test_receipt_service.py @@ -350,6 +350,57 @@ async def test_update_receipt_item( mock_session.flush.assert_called_once() +@pytest.mark.asyncio +async def test_update_receipt_item_recomputes_total( + receipt_service: ReceiptService, mock_session: AsyncMock +) -> None: + """Test updating a receipt item recomputes totals.""" + # Arrange + item = ReceiptItem( + id=1, + name="Original Item", + quantity=1, + unit_price=Decimal("5.00"), + total_price=Decimal("5.00"), + currency="$", + category_id=1, + receipt_id=1, + ) + other_item = ReceiptItem( + id=2, + name="Other Item", + quantity=1, + unit_price=Decimal("10.00"), + total_price=Decimal("10.00"), + currency="$", + category_id=1, + receipt_id=1, + ) + receipt = Receipt( + id=1, + store_name="Test Store", + total_amount=Decimal("15.00"), + currency="$", + image_path="/path/to/image.jpg", + items=[item, other_item], + ) + mock_session.scalar.return_value = receipt + mock_session.flush = AsyncMock() + mock_session.refresh = AsyncMock() + + update_data = ReceiptItemUpdate(quantity=2) + + # Act + updated_receipt = await receipt_service.update_item( + receipt_id=1, item_id=1, item_in=update_data, user_id=TEST_USER_ID + ) + + # Assert + assert item.total_price == Decimal("10.00") + assert updated_receipt.total_amount == Decimal("20.00") + mock_session.flush.assert_called_once() + + @pytest.mark.asyncio async def test_update_nonexistent_receipt_item( receipt_service: ReceiptService, mock_session: AsyncMock @@ -394,6 +445,7 @@ async def test_update_receipt_with_metadata( tax_amount=None, tags=[], ) + assert existing_receipt.id is not None # Mock the scalar method for get mock_session.scalar.return_value = existing_receipt @@ -462,13 +514,22 @@ async def test_create_item( ) -> None: """Test creating a receipt item and updating receipt total.""" # Arrange + existing_item = ReceiptItem( + id=2, + name="Existing Item", + quantity=1, + unit_price=Decimal("10.00"), + total_price=Decimal("10.00"), + currency="$", + receipt_id=1, + ) receipt = Receipt( id=1, store_name="Test Store", total_amount=Decimal("10.00"), currency="$", image_path="/path/to/image.jpg", - items=[], + items=[existing_item], ) mock_session.scalar.return_value = receipt mock_session.flush = AsyncMock() @@ -491,7 +552,7 @@ async def test_create_item( # Total should be original (10.00) + new item total (2 * 5.50 = 11.00) = 21.00 assert updated_receipt.total_amount == Decimal("21.00") mock_session.add.assert_called() - mock_session.flush.assert_called_once() + assert mock_session.flush.call_count == 2 @pytest.mark.asyncio @@ -532,13 +593,22 @@ async def test_delete_item( currency="$", receipt_id=1, ) + remaining_item = ReceiptItem( + id=2, + name="Remaining Item", + quantity=1, + unit_price=Decimal("10.00"), + total_price=Decimal("10.00"), + currency="$", + receipt_id=1, + ) receipt = Receipt( id=1, store_name="Test Store", total_amount=Decimal("15.00"), currency="$", image_path="/path/to/image.jpg", - items=[item], + items=[item, remaining_item], ) mock_session.scalar.return_value = receipt mock_session.delete = AsyncMock() @@ -554,7 +624,7 @@ async def test_delete_item( # Total should be original (15.00) - deleted item (5.00) = 10.00 assert updated_receipt.total_amount == Decimal("10.00") mock_session.delete.assert_called_once_with(item) - mock_session.flush.assert_called_once() + assert mock_session.flush.call_count == 2 @pytest.mark.asyncio diff --git a/frontend/src/app/(app)/receipts/[id]/page.tsx b/frontend/src/app/(app)/receipts/[id]/page.tsx index 4a66acf..aa73900 100644 --- a/frontend/src/app/(app)/receipts/[id]/page.tsx +++ b/frontend/src/app/(app)/receipts/[id]/page.tsx @@ -413,7 +413,7 @@ export default function ReceiptDetailPage({ params }: PageProps) { > {/* eslint-disable-next-line @next/next/no-img-element */} Receipt @@ -560,7 +560,7 @@ export default function ReceiptDetailPage({ params }: PageProps) { {/* eslint-disable-next-line @next/next/no-img-element */} Receipt diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 4719328..c175fda 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { LoginForm } from "@/components/auth/login-form"; @@ -12,6 +12,7 @@ import type { LoginCredentials } from "@/types"; export default function LoginPage() { const router = useRouter(); + const searchParams = useSearchParams(); const loginMutation = useLogin(); const [error, setError] = useState(null); @@ -20,7 +21,12 @@ export default function LoginPage() { try { await loginMutation.mutateAsync(credentials); toast.success("Login successful!"); - router.push("/"); + const redirect = searchParams.get("redirect"); + const destination = + redirect && redirect.startsWith("/") && !redirect.startsWith("//") + ? redirect + : "/"; + router.push(destination); } catch (err) { const message = err instanceof Error ? err.message : "Login failed"; setError(message); diff --git a/frontend/src/components/scan/dropzone.tsx b/frontend/src/components/scan/dropzone.tsx index 925c1c7..c67da38 100644 --- a/frontend/src/components/scan/dropzone.tsx +++ b/frontend/src/components/scan/dropzone.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from "react"; import { cn } from "@/lib/utils"; import { Upload, Image as ImageIcon, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; interface DropzoneProps { onFileSelect: (file: File) => void; @@ -11,6 +12,9 @@ interface DropzoneProps { fullPage?: boolean; } +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; +const MAX_FILE_SIZE_MB = 10; + export function Dropzone({ onFileSelect, disabled, fullPage }: DropzoneProps) { const [isDragging, setIsDragging] = useState(false); const [preview, setPreview] = useState(null); @@ -21,6 +25,12 @@ export function Dropzone({ onFileSelect, disabled, fullPage }: DropzoneProps) { if (!file.type.startsWith("image/")) { return; } + if (file.size > MAX_FILE_SIZE_BYTES) { + toast.error( + `File is too large. Maximum size is ${MAX_FILE_SIZE_MB}MB.` + ); + return; + } setSelectedFile(file); const reader = new FileReader(); From 2583a378221a641df55626f38e986f8951dbafd8 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 4 Feb 2026 22:28:57 +0000 Subject: [PATCH 02/10] Apply pre-commit hook fixes --- backend/app/main.py | 1 + backend/app/receipt/services.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 286d359..e77fcda 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -78,6 +78,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: app.include_router(category_router) app.include_router(analytics_router) + # Define the root endpoint @app.get("/", include_in_schema=False) async def root() -> dict[str, str]: diff --git a/backend/app/receipt/services.py b/backend/app/receipt/services.py index aa6553e..f5b03e0 100644 --- a/backend/app/receipt/services.py +++ b/backend/app/receipt/services.py @@ -65,7 +65,9 @@ def resolve_image_path(self, image_path: str) -> Path: if path.is_absolute(): candidate = path - elif upload_dir.parts and path.parts[: len(upload_dir.parts)] == upload_dir.parts: + elif ( + upload_dir.parts and path.parts[: len(upload_dir.parts)] == upload_dir.parts + ): candidate = (Path.cwd() / path).resolve(strict=False) else: candidate = (upload_root / path).resolve(strict=False) From b8f89cec34bee6f0d7bad64f83af92d433758ec9 Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Wed, 4 Feb 2026 23:31:34 +0000 Subject: [PATCH 03/10] Fix PR review feedback - Add same-origin proxy route for receipt images and update frontend usage - Simplify create_item total recalculation and fix unit test - Ensure API route cookie access is awaited --- backend/app/receipt/services.py | 6 ++-- .../unit/receipt/test_receipt_service.py | 2 +- frontend/src/app/(app)/receipts/[id]/page.tsx | 4 +-- .../src/app/api/receipts/[id]/image/route.ts | 34 +++++++++++++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/api/receipts/[id]/image/route.ts diff --git a/backend/app/receipt/services.py b/backend/app/receipt/services.py index f5b03e0..f8df24e 100644 --- a/backend/app/receipt/services.py +++ b/backend/app/receipt/services.py @@ -451,6 +451,7 @@ async def create_item( receipt = await self.session.scalar(stmt) if not receipt: raise NotFoundError(f"Receipt with id {receipt_id} not found") + await self.session.refresh(receipt, ["items"]) # Validate currency matches the receipt if item_in.currency != receipt.currency: @@ -475,10 +476,7 @@ async def create_item( self.session.add(item) - await self.session.flush() - await self.session.refresh(receipt, ["items"]) - if item not in receipt.items: - receipt.items.append(item) + receipt.items.append(item) # Update the receipt total receipt.total_amount = self._recalculate_total(receipt) diff --git a/backend/tests/unit/receipt/test_receipt_service.py b/backend/tests/unit/receipt/test_receipt_service.py index 58115ff..d4f25a7 100644 --- a/backend/tests/unit/receipt/test_receipt_service.py +++ b/backend/tests/unit/receipt/test_receipt_service.py @@ -552,7 +552,7 @@ async def test_create_item( # Total should be original (10.00) + new item total (2 * 5.50 = 11.00) = 21.00 assert updated_receipt.total_amount == Decimal("21.00") mock_session.add.assert_called() - assert mock_session.flush.call_count == 2 + mock_session.flush.assert_called_once() @pytest.mark.asyncio diff --git a/frontend/src/app/(app)/receipts/[id]/page.tsx b/frontend/src/app/(app)/receipts/[id]/page.tsx index aa73900..2725bd4 100644 --- a/frontend/src/app/(app)/receipts/[id]/page.tsx +++ b/frontend/src/app/(app)/receipts/[id]/page.tsx @@ -413,7 +413,7 @@ export default function ReceiptDetailPage({ params }: PageProps) { > {/* eslint-disable-next-line @next/next/no-img-element */} Receipt @@ -560,7 +560,7 @@ export default function ReceiptDetailPage({ params }: PageProps) { {/* eslint-disable-next-line @next/next/no-img-element */} Receipt diff --git a/frontend/src/app/api/receipts/[id]/image/route.ts b/frontend/src/app/api/receipts/[id]/image/route.ts new file mode 100644 index 0000000..a451f45 --- /dev/null +++ b/frontend/src/app/api/receipts/[id]/image/route.ts @@ -0,0 +1,34 @@ +import { cookies } from "next/headers"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; +const TOKEN_COOKIE_KEY = "receipt_scanner_token"; + +export async function GET( + _request: Request, + { params }: { params: { id: string } } +) { + const cookieStore = await cookies(); + const token = cookieStore.get(TOKEN_COOKIE_KEY)?.value; + + if (!token) { + return new Response("Unauthorized", { status: 401 }); + } + + const url = `${API_BASE_URL}/api/v1/receipts/${params.id}/image`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + const text = await response.text(); + return new Response(text || response.statusText, { status: response.status }); + } + + const contentType = + response.headers.get("content-type") || "application/octet-stream"; + + return new Response(response.body, { + status: response.status, + headers: { "Content-Type": contentType }, + }); +} From a9ac5f88554bfca71b4ffa36f28708e7280cc51e Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Wed, 4 Feb 2026 23:34:33 +0000 Subject: [PATCH 04/10] Fix Next.js route params typing - Align app route handler signature with Next.js 16 params Promise --- frontend/src/app/api/receipts/[id]/image/route.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/api/receipts/[id]/image/route.ts b/frontend/src/app/api/receipts/[id]/image/route.ts index a451f45..d441f43 100644 --- a/frontend/src/app/api/receipts/[id]/image/route.ts +++ b/frontend/src/app/api/receipts/[id]/image/route.ts @@ -1,11 +1,12 @@ +import type { NextRequest } from "next/server"; import { cookies } from "next/headers"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; const TOKEN_COOKIE_KEY = "receipt_scanner_token"; export async function GET( - _request: Request, - { params }: { params: { id: string } } + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } ) { const cookieStore = await cookies(); const token = cookieStore.get(TOKEN_COOKIE_KEY)?.value; @@ -14,7 +15,8 @@ export async function GET( return new Response("Unauthorized", { status: 401 }); } - const url = `${API_BASE_URL}/api/v1/receipts/${params.id}/image`; + const { id } = await params; + const url = `${API_BASE_URL}/api/v1/receipts/${id}/image`; const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); From d0910e5bd2a94d62c0f20089f91c6e5d6d37ec20 Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Wed, 4 Feb 2026 23:36:36 +0000 Subject: [PATCH 05/10] Fix login redirect without useSearchParams --- frontend/src/app/(auth)/login/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index c175fda..10b6aeb 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { LoginForm } from "@/components/auth/login-form"; @@ -12,7 +12,6 @@ import type { LoginCredentials } from "@/types"; export default function LoginPage() { const router = useRouter(); - const searchParams = useSearchParams(); const loginMutation = useLogin(); const [error, setError] = useState(null); @@ -21,7 +20,7 @@ export default function LoginPage() { try { await loginMutation.mutateAsync(credentials); toast.success("Login successful!"); - const redirect = searchParams.get("redirect"); + const redirect = new URLSearchParams(window.location.search).get("redirect"); const destination = redirect && redirect.startsWith("/") && !redirect.startsWith("//") ? redirect From 5df682cd63d85b43abcb9d4a5b148ea466658b05 Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Wed, 4 Feb 2026 23:42:10 +0000 Subject: [PATCH 06/10] Set Turbopack root to frontend --- frontend/next.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 68a6c64..8aa5a18 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + turbopack: { + root: __dirname, + }, }; export default nextConfig; From 5b184232c2dd11cc184771024d3c53ca2e11e138 Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Sun, 8 Feb 2026 02:36:14 +0000 Subject: [PATCH 07/10] feat(backend): add AI receipt reconciliation with duplicate-line fallback --- .../pydantic_ai/receipt_reconcile_agent.py | 148 ++++++++ .../pydantic_ai/receipt_reconcile_prompt.py | 22 ++ .../pydantic_ai/receipt_reconcile_schema.py | 28 ++ backend/app/receipt/models.py | 33 ++ backend/app/receipt/router.py | 23 +- backend/app/receipt/services.py | 330 +++++++++++++++++- 6 files changed, 567 insertions(+), 17 deletions(-) create mode 100644 backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py create mode 100644 backend/app/integrations/pydantic_ai/receipt_reconcile_prompt.py create mode 100644 backend/app/integrations/pydantic_ai/receipt_reconcile_schema.py diff --git a/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py b/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py new file mode 100644 index 0000000..8363a43 --- /dev/null +++ b/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py @@ -0,0 +1,148 @@ +import os +from dataclasses import dataclass +from functools import cache +from io import BytesIO +from typing import Any + +import httpx +from google.genai.types import ThinkingLevel +from PIL import Image +from pydantic_ai import Agent, RunContext +from pydantic_ai.messages import BinaryContent +from pydantic_ai.models.google import GoogleModel, GoogleModelSettings +from pydantic_ai.models.instrumented import InstrumentationSettings +from pydantic_ai.providers.google import GoogleProvider +from pydantic_ai.retries import AsyncTenacityTransport, RetryConfig, wait_retry_after +from tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential + +from app.core.config import settings +from app.core.exceptions import ServiceUnavailableError +from app.integrations.pydantic_ai.receipt_reconcile_prompt import ( + RECEIPT_RECONCILE_SYSTEM_PROMPT, +) +from app.integrations.pydantic_ai.receipt_reconcile_schema import ( + ReceiptReconcileAnalysis, +) + +# Model configuration - use Gemini 3 Flash by default (faster + cheaper than Pro) +DEFAULT_MODEL = "gemini-3-flash-preview" + +DEFAULT_MODEL_SETTINGS = GoogleModelSettings( + timeout=120, + google_thinking_config={"thinking_level": ThinkingLevel.LOW}, +) + + +def _create_retrying_http_client() -> httpx.AsyncClient: + """Create an HTTP client with smart retry handling for transient errors.""" + + def should_retry_status(response: httpx.Response) -> None: + if response.status_code in (429, 502, 503, 504): + response.raise_for_status() + + transport = AsyncTenacityTransport( + config=RetryConfig( + retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.ConnectError)), + wait=wait_retry_after( + fallback_strategy=wait_exponential(multiplier=2, max=30), + max_wait=120, + ), + stop=stop_after_attempt(3), + reraise=True, + ), + validate_response=should_retry_status, + ) + return httpx.AsyncClient(transport=transport, timeout=120) + + +@dataclass +class ReceiptReconcileDependencies: + """Dependencies for receipt reconciliation.""" + + image_bytes: bytes + receipt_total: str + items: list[dict[str, Any]] + + +@cache +def get_receipt_reconcile_agent() -> Agent[ + ReceiptReconcileDependencies, ReceiptReconcileAnalysis +]: + """Lazily create and cache the receipt reconciliation agent.""" + model_name = os.getenv("GEMINI_MODEL", DEFAULT_MODEL) + + http_client = _create_retrying_http_client() + google_provider = GoogleProvider( + api_key=settings.GEMINI_API_KEY, + http_client=http_client, + ) + google_model = GoogleModel(model_name, provider=google_provider) + + instrumentation = InstrumentationSettings( + include_content=True, + include_binary_content=settings.ENVIRONMENT.lower() != "production", + version=2, + ) + + agent: Agent[ReceiptReconcileDependencies, ReceiptReconcileAnalysis] = Agent( + model=google_model, + deps_type=ReceiptReconcileDependencies, + output_type=ReceiptReconcileAnalysis, + system_prompt=RECEIPT_RECONCILE_SYSTEM_PROMPT, + model_settings=DEFAULT_MODEL_SETTINGS, + retries=3, + instrument=instrumentation, + ) + + @agent.system_prompt + def receipt_context(ctx: RunContext[ReceiptReconcileDependencies]) -> str: + items_info = "\n".join( + [ + ( + f"- id:{item['id']} name:{item['name']} " + f"qty:{item['quantity']} unit_price:{item['unit_price']} " + f"total:{item['total_price']} currency:{item['currency']}" + ) + for item in ctx.deps.items + ] + ) + + return f""" +Receipt total: {ctx.deps.receipt_total} +Items: +{items_info} +""" + + return agent + + +async def analyze_reconciliation( + image: Image.Image, + receipt_total: str, + items: list[dict[str, Any]], +) -> ReceiptReconcileAnalysis: + """Reconcile receipt items using Pydantic AI agent with Gemini Vision.""" + try: + img_byte_arr = BytesIO() + image.save(img_byte_arr, format="PNG") + img_bytes = img_byte_arr.getvalue() + + deps = ReceiptReconcileDependencies( + image_bytes=img_bytes, + receipt_total=receipt_total, + items=items, + ) + + messages: list[str | BinaryContent] = [ + "Reconcile by marking duplicate/noise items for removal only.", + BinaryContent(data=img_bytes, media_type="image/png"), + ] + + agent = get_receipt_reconcile_agent() + result = await agent.run(messages, deps=deps) + return result.output + + except Exception as e: + raise ServiceUnavailableError( + f"Error reconciling receipt: {str(e)}" + ) from e diff --git a/backend/app/integrations/pydantic_ai/receipt_reconcile_prompt.py b/backend/app/integrations/pydantic_ai/receipt_reconcile_prompt.py new file mode 100644 index 0000000..4e5c97d --- /dev/null +++ b/backend/app/integrations/pydantic_ai/receipt_reconcile_prompt.py @@ -0,0 +1,22 @@ +"""Receipt reconciliation system prompt.""" + +RECEIPT_RECONCILE_SYSTEM_PROMPT = """You are a receipt reconciliation assistant. + +Your job is to reconcile a scanned receipt's line items with the receipt total. + +Rules: +1. Treat the receipt header total as authoritative. +2. You may ONLY suggest setting remove=true on existing items that are duplicated/noisy OCR lines. +3. Do NOT invent new items. +4. Use the provided item_id when suggesting changes. +5. Prefer removing obvious duplicated lines over any other strategy. +6. If you detect repeated item sequences or repeated blocks, mark the extra block items with remove=true. +7. If you are unsure or no safe changes are needed, return an empty adjustments list. +8. Provide a short reason for each removal (one short sentence, no calculations). +9. Never claim items already match unless the PROVIDED item list (after your suggested + adjustments) sums to the receipt total within 0.05 tolerance. +10. If current items do not match and you cannot confidently identify duplicates, + return empty adjustments and explain uncertainty briefly. + +The receipt image is provided to help you verify the correct line items and prices. +""" diff --git a/backend/app/integrations/pydantic_ai/receipt_reconcile_schema.py b/backend/app/integrations/pydantic_ai/receipt_reconcile_schema.py new file mode 100644 index 0000000..4d925d9 --- /dev/null +++ b/backend/app/integrations/pydantic_ai/receipt_reconcile_schema.py @@ -0,0 +1,28 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class ReceiptItemAdjustment(BaseModel): + """Suggested adjustment for a receipt item.""" + + item_id: int = Field(description="ID of the existing item to adjust") + remove: Literal[True] = Field( + default=True, + description=( + "Remove this item from the receipt when it looks like a duplicate or OCR noise" + ), + ) + reason: str | None = Field( + default=None, + description="Brief reason for removing this item (1 short sentence)", + max_length=180, + ) + + +class ReceiptReconcileAnalysis(BaseModel): + """AI reconciliation suggestions for a receipt.""" + + adjustments: list[ReceiptItemAdjustment] = Field( + description="List of existing items to remove" + ) diff --git a/backend/app/receipt/models.py b/backend/app/receipt/models.py index 2bac70f..6b6a239 100644 --- a/backend/app/receipt/models.py +++ b/backend/app/receipt/models.py @@ -269,6 +269,17 @@ class ReceiptItemRead(ReceiptItemBase): model_config = SQLModelConfig(from_attributes=True) +class ScanRemovedItem(SQLModel): + """Scan-time item removed by deterministic duplicate cleanup.""" + + name: str + quantity: int + unit_price: Decimal + total_price: Decimal + currency: str + category_id: int | None = None + + class ReceiptRead(ReceiptBase): """Schema for reading a receipt.""" @@ -277,5 +288,27 @@ class ReceiptRead(ReceiptBase): updated_at: datetime tags: list[str] items: list[ReceiptItemRead] + scan_removed_items: list[ScanRemovedItem] | None = None model_config = SQLModelConfig(from_attributes=True) + + +class ReceiptItemAdjustment(SQLModel): + """Suggested adjustment for a receipt item.""" + + item_id: int + remove: bool = True + reason: str | None = None + + +class ReceiptReconcileSuggestion(SQLModel): + """AI reconciliation suggestion for a receipt mismatch.""" + + receipt_id: int + receipt_total: Decimal + items_total: Decimal + difference: Decimal + adjusted_items_total: Decimal + remaining_difference: Decimal + adjustments: list[ReceiptItemAdjustment] + notes: str | None = None diff --git a/backend/app/receipt/router.py b/backend/app/receipt/router.py index 3c9a190..59e98e7 100644 --- a/backend/app/receipt/router.py +++ b/backend/app/receipt/router.py @@ -17,6 +17,7 @@ ReceiptItemRead, ReceiptItemUpdate, ReceiptRead, + ReceiptReconcileSuggestion, ReceiptUpdate, ) from app.receipt.services import ReceiptFilters @@ -73,7 +74,7 @@ async def create_receipt_from_scan( current_user: CurrentUser, service: ReceiptDeps, image: Annotated[UploadFile, File()], -) -> Receipt: +) -> ReceiptRead: """ Upload and scan a receipt image. The image will be analyzed using AI to extract information. @@ -352,6 +353,21 @@ async def update_receipt( return await service.update(receipt_id, receipt_in, user_id=user_id) +@router.post( + "/{receipt_id}/reconcile", + response_model=ReceiptReconcileSuggestion, + status_code=status.HTTP_200_OK, +) +async def reconcile_receipt( + receipt_id: int, + current_user: CurrentUser, + service: ReceiptDeps, +) -> ReceiptReconcileSuggestion: + """Suggest AI adjustments to reconcile receipt items with the receipt total.""" + user_id = require_user_id(current_user) + return await service.reconcile_items(receipt_id, user_id=user_id) + + @router.delete("/{receipt_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_receipt( receipt_id: int, @@ -393,7 +409,7 @@ async def create_receipt_item( ) -> Receipt: """Create a new item for a receipt. - Creates the item and updates the receipt total automatically. + Creates the item and returns the updated receipt. """ user_id = require_user_id(current_user) return await service.create_item(receipt_id, item_in, user_id=user_id) @@ -412,8 +428,7 @@ async def delete_receipt_item( ) -> Receipt: """Delete a receipt item. - Deletes the item and updates the receipt total automatically. - Returns the updated receipt with remaining items. + Deletes the item and returns the updated receipt with remaining items. """ user_id = require_user_id(current_user) return await service.delete_item(receipt_id, item_id, user_id=user_id) diff --git a/backend/app/receipt/services.py b/backend/app/receipt/services.py index f8df24e..bef0355 100644 --- a/backend/app/receipt/services.py +++ b/backend/app/receipt/services.py @@ -1,6 +1,7 @@ import csv import os import uuid +from collections import Counter from collections.abc import Sequence from datetime import UTC, datetime, timedelta from decimal import Decimal @@ -18,15 +19,22 @@ from app.core.config import settings from app.core.exceptions import BadRequestError, NotFoundError, ServiceUnavailableError from app.integrations.pydantic_ai.receipt_agent import analyze_receipt +from app.integrations.pydantic_ai.receipt_reconcile_agent import ( + analyze_reconciliation, +) from app.receipt.exporters import ReceiptPDFGenerator from app.receipt.models import ( Receipt, ReceiptCreate, ReceiptItem, + ReceiptItemAdjustment, ReceiptItemCreate, ReceiptItemCreateRequest, ReceiptItemUpdate, + ReceiptRead, + ReceiptReconcileSuggestion, ReceiptUpdate, + ScanRemovedItem, ) @@ -42,6 +50,11 @@ class ReceiptFilters(TypedDict, total=False): max_amount: Decimal | None +CentsList = list[int] +ReceiptItemList = list[ReceiptItem] +ReceiptItemAdjustmentList = list[ReceiptItemAdjustment] + + class ReceiptService: """Service for managing receipts and receipt items.""" @@ -57,6 +70,153 @@ def _recalculate_total(self, receipt: Receipt) -> Decimal: """Recalculate receipt total from item totals.""" return sum((item.total_price for item in receipt.items), Decimal("0")) + @staticmethod + def _to_cents(amount: Decimal) -> int: + """Convert a decimal amount to integer cents.""" + return int((amount * 100).quantize(Decimal("1"))) + + @staticmethod + def _is_better_subset( + candidate: tuple[int, int, tuple[int, ...]], + current: tuple[int, int, tuple[int, ...]], + ) -> bool: + """Compare subset candidates by count first, then by earlier-line preference.""" + candidate_count, candidate_score, _ = candidate + current_count, current_score, _ = current + if candidate_count != current_count: + return candidate_count > current_count + return candidate_score > current_score + + def _find_subset_indices_matching_total( + self, item_totals_cents: CentsList, target_cents: int + ) -> set[int] | None: + """Find subset indices that sum exactly to target cents. + + Returns the highest-confidence subset by: + 1. Keeping the most lines + 2. Preferring earlier lines when counts tie + """ + n_items = len(item_totals_cents) + best_by_sum: dict[int, tuple[int, int, tuple[int, ...]]] = { + 0: (0, 0, ()) + } + + for idx, amount_cents in enumerate(item_totals_cents): + existing_states = list(best_by_sum.items()) + for current_sum, state in existing_states: + next_sum = current_sum + amount_cents + if next_sum > target_cents: + continue + + count, score, indices = state + # Larger score means the subset keeps earlier lines. + candidate = (count + 1, score + (n_items - idx), indices + (idx,)) + current_best = best_by_sum.get(next_sum) + if current_best is None or self._is_better_subset( + candidate, current_best + ): + best_by_sum[next_sum] = candidate + + result = best_by_sum.get(target_cents) + if result is None: + return None + return set(result[2]) + + def _dedupe_scanned_items_by_total( + self, + items: ReceiptItemList, + expected_total: Decimal, + *, + tolerance: Decimal = Decimal("0.05"), + ) -> tuple[ReceiptItemList, ReceiptItemList, str | None]: + """Drop clearly duplicated OCR lines when item sum exceeds receipt total. + + The heuristic only applies when: + - an exact subset matches receipt total, and + - every removed line has at least one duplicate signature. + """ + if not items: + return items, [], None + + item_totals_cents = [self._to_cents(item.total_price) for item in items] + current_total_cents = sum(item_totals_cents) + expected_total_cents = self._to_cents(expected_total) + tolerance_cents = self._to_cents(tolerance) + + if abs(current_total_cents - expected_total_cents) <= tolerance_cents: + return items, [], None + if current_total_cents <= expected_total_cents: + return items, [], None + + # Guard runtime for very large receipts. + if len(items) > 120 or expected_total_cents > 500_000: + return items, [], None + + keep_indices = self._find_subset_indices_matching_total( + item_totals_cents, expected_total_cents + ) + if not keep_indices or len(keep_indices) == len(items): + return items, [], None + + removed_indices = sorted(set(range(len(items))) - keep_indices) + signatures = [ + ( + item.name.strip().upper(), + self._to_cents(item.total_price), + item.currency, + ) + for item in items + ] + signature_counts = Counter(signatures) + + # Only auto-drop lines that look duplicated in extracted output. + if any(signature_counts[signatures[idx]] < 2 for idx in removed_indices): + return items, [], None + + filtered_items = [items[idx] for idx in sorted(keep_indices)] + removed_items = [items[idx] for idx in removed_indices] + filtered_total_cents = sum( + self._to_cents(item.total_price) for item in filtered_items + ) + if abs(filtered_total_cents - expected_total_cents) > tolerance_cents: + return items, [], None + + note = ( + f"Auto-removed {len(removed_indices)} likely duplicate line(s) " + "to align item sum with receipt total." + ) + return filtered_items, removed_items, note + + def _fallback_duplicate_removal_adjustments( + self, items: ReceiptItemList, expected_total: Decimal + ) -> tuple[ReceiptItemAdjustmentList, str | None]: + """Build deterministic remove-adjustments when AI returns no actionable output.""" + _, removed_items, note = self._dedupe_scanned_items_by_total(items, expected_total) + adjustments: ReceiptItemAdjustmentList = [] + for item in removed_items: + if item.id is None: + continue + adjustments.append( + ReceiptItemAdjustment( + item_id=item.id, + remove=True, + reason="Likely duplicated OCR line based on repeated signatures and total mismatch.", + ) + ) + return adjustments, note + + @staticmethod + def _normalize_reconcile_reason(reason: str | None) -> str: + """Normalize reconcile reasons to a concise single sentence for UI.""" + if not reason: + return "Likely duplicate/noise line." + compact = " ".join(reason.replace("\n", " ").split()) + if ". " in compact: + compact = compact.split(". ", 1)[0] + if len(compact) > 180: + compact = f"{compact[:177].rstrip()}..." + return compact + def resolve_image_path(self, image_path: str) -> Path: """Resolve and validate receipt image path within upload directory.""" upload_root = settings.UPLOAD_DIR.resolve() @@ -88,7 +248,7 @@ async def create(self, receipt_in: ReceiptCreate, user_id: int) -> Receipt: await self.session.flush() return receipt - async def create_from_scan(self, image_file: UploadFile, user_id: int) -> Receipt: + async def create_from_scan(self, image_file: UploadFile, user_id: int) -> ReceiptRead: """Create a receipt from an uploaded image file. This method: @@ -200,12 +360,36 @@ async def create_from_scan(self, image_file: UploadFile, user_id: int) -> Receip receipt_items.append(receipt_item) # Add items to database + receipt_items, removed_items, auto_note = self._dedupe_scanned_items_by_total( + receipt_items, receipt.total_amount + ) + if auto_note: + receipt.notes = ( + f"{receipt.notes}\n{auto_note}".strip() + if receipt.notes + else auto_note + ) + for item in receipt_items: self.session.add(item) await self.session.flush() # Get the updated receipt with items - return await self.get(receipt_id, user_id=user_id) + scanned_receipt = await self.get(receipt_id, user_id=user_id) + scan_response = ReceiptRead.model_validate(scanned_receipt) + if removed_items: + scan_response.scan_removed_items = [ + ScanRemovedItem( + name=item.name, + quantity=item.quantity, + unit_price=item.unit_price, + total_price=item.total_price, + currency=item.currency, + category_id=item.category_id, + ) + for item in removed_items + ] + return scan_response except Exception: # Clean up the saved file on any failure @@ -371,7 +555,6 @@ async def update_item( item.total_price = item.unit_price * item.quantity item.updated_at = datetime.now(UTC) - receipt.total_amount = self._recalculate_total(receipt) receipt.updated_at = datetime.now(UTC) await self.session.flush() @@ -379,6 +562,135 @@ async def update_item( return receipt + async def reconcile_items( + self, receipt_id: int, user_id: int + ) -> ReceiptReconcileSuggestion: + """Suggest AI-based adjustments to reconcile items with receipt total.""" + receipt = await self.get(receipt_id, user_id) + + if not receipt.items: + raise BadRequestError("Receipt has no items to reconcile") + + receipt_total = Decimal(str(receipt.total_amount)) + items_total = sum((item.total_price for item in receipt.items), Decimal("0")) + difference = items_total - receipt_total + + if abs(difference) <= Decimal("0.05"): + return ReceiptReconcileSuggestion( + receipt_id=receipt_id, + receipt_total=receipt_total, + items_total=items_total, + difference=difference, + adjusted_items_total=items_total, + remaining_difference=Decimal("0"), + adjustments=[], + notes="Items already match receipt total.", + ) + + # Load receipt image for AI reconciliation + image_path = self.resolve_image_path(receipt.image_path) + try: + image = Image.open(image_path) + except Exception as e: + raise BadRequestError(f"Invalid receipt image: {e}") from e + + items_context: list[dict[str, str | int | Decimal]] = [] + for item in receipt.items: + if item.id is None: + raise ServiceUnavailableError("Receipt item missing ID") + items_context.append( + { + "id": item.id, + "name": item.name, + "quantity": item.quantity, + "unit_price": item.unit_price, + "total_price": item.total_price, + "currency": item.currency, + } + ) + + analysis = await analyze_reconciliation( + image=image, + receipt_total=str(receipt_total), + items=items_context, + ) + + notes: list[str] = [] + + valid_item_ids = { + item.id for item in receipt.items if item.id is not None + } + + adjustments_by_id: dict[int, ReceiptItemAdjustment] = {} + for adjustment in analysis.adjustments: + if adjustment.item_id not in valid_item_ids: + notes.append( + f"Ignored adjustment for unknown item id {adjustment.item_id}." + ) + continue + if adjustment.item_id in adjustments_by_id: + notes.append( + f"Ignored duplicate adjustment for item id {adjustment.item_id}." + ) + continue + if not adjustment.remove: + notes.append( + f"Ignored non-remove adjustment for item id {adjustment.item_id}." + ) + continue + adjustments_by_id[adjustment.item_id] = ReceiptItemAdjustment( + item_id=adjustment.item_id, + remove=True, + reason=self._normalize_reconcile_reason(adjustment.reason), + ) + + if not adjustments_by_id and abs(difference) > Decimal("0.05"): + fallback_adjustments, fallback_note = self._fallback_duplicate_removal_adjustments( + list(receipt.items), receipt_total + ) + if fallback_adjustments: + notes.append( + "Applied deterministic duplicate-line fallback because AI returned no actionable adjustments." + ) + if fallback_note: + notes.append(fallback_note) + adjustments_by_id.update( + {adjustment.item_id: adjustment for adjustment in fallback_adjustments} + ) + + # Validate adjustments and compute adjusted total + adjusted_total = Decimal("0") + for item in receipt.items: + if item.id is None: + raise ServiceUnavailableError("Receipt item missing ID") + adjustment = adjustments_by_id.get(item.id) + if adjustment and adjustment.remove: + continue + adjusted_total += item.total_price + + remaining_difference = adjusted_total - receipt_total + if abs(remaining_difference) > Decimal("0.05"): + notes.append( + "AI suggestions did not fully reconcile the total. " + f"Remaining difference: {remaining_difference}" + ) + + if not adjustments_by_id and abs(difference) > Decimal("0.05"): + notes.append("AI did not provide actionable adjustments.") + + notes_text = " ".join(notes) if notes else None + + return ReceiptReconcileSuggestion( + receipt_id=receipt_id, + receipt_total=receipt_total, + items_total=items_total, + difference=difference, + adjusted_items_total=adjusted_total, + remaining_difference=remaining_difference, + adjustments=list(adjustments_by_id.values()), + notes=notes_text, + ) + async def create_items( self, receipt_id: int, items_in: Sequence[ReceiptItemCreate], user_id: int ) -> Sequence[ReceiptItem]: @@ -432,7 +744,7 @@ async def list_items_by_category( async def create_item( self, receipt_id: int, item_in: ReceiptItemCreateRequest, user_id: int ) -> Receipt: - """Create a single receipt item and update the receipt total. + """Create a single receipt item. Args: receipt_id: The ID of the receipt to add the item to @@ -477,9 +789,6 @@ async def create_item( self.session.add(item) receipt.items.append(item) - - # Update the receipt total - receipt.total_amount = self._recalculate_total(receipt) receipt.updated_at = datetime.now(UTC) await self.session.flush() @@ -488,7 +797,7 @@ async def create_item( return receipt async def delete_item(self, receipt_id: int, item_id: int, user_id: int) -> Receipt: - """Delete a receipt item and update the receipt total. + """Delete a receipt item. Args: receipt_id: The ID of the receipt @@ -524,13 +833,8 @@ async def delete_item(self, receipt_id: int, item_id: int, user_id: int) -> Rece await self.session.flush() await self.session.refresh(receipt, ["items"]) - # Update the receipt total - receipt.total_amount = self._recalculate_total(receipt) receipt.updated_at = datetime.now(UTC) - await self.session.flush() - await self.session.refresh(receipt, ["items"]) - return receipt async def export_to_csv( From 09454919881dd2e9d744422cd901966dbc34956b Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Sun, 8 Feb 2026 02:36:25 +0000 Subject: [PATCH 08/10] test(backend): cover scan dedupe and reconcile fallback --- .../unit/receipt/test_receipt_service.py | 206 +++++++++++++++++- 1 file changed, 196 insertions(+), 10 deletions(-) diff --git a/backend/tests/unit/receipt/test_receipt_service.py b/backend/tests/unit/receipt/test_receipt_service.py index d4f25a7..da80142 100644 --- a/backend/tests/unit/receipt/test_receipt_service.py +++ b/backend/tests/unit/receipt/test_receipt_service.py @@ -1,10 +1,15 @@ """Unit tests for the receipt domain.""" from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from typing import cast from unittest.mock import AsyncMock, MagicMock import pytest +from PIL import Image +from app.auth.models import User # noqa: F401 from app.category.services import CategoryService from app.core.exceptions import BadRequestError, NotFoundError from app.receipt.models import ( @@ -351,10 +356,10 @@ async def test_update_receipt_item( @pytest.mark.asyncio -async def test_update_receipt_item_recomputes_total( +async def test_update_receipt_item_does_not_change_receipt_total( receipt_service: ReceiptService, mock_session: AsyncMock ) -> None: - """Test updating a receipt item recomputes totals.""" + """Test updating a receipt item does not change receipt total.""" # Arrange item = ReceiptItem( id=1, @@ -397,7 +402,7 @@ async def test_update_receipt_item_recomputes_total( # Assert assert item.total_price == Decimal("10.00") - assert updated_receipt.total_amount == Decimal("20.00") + assert updated_receipt.total_amount == Decimal("15.00") mock_session.flush.assert_called_once() @@ -512,7 +517,7 @@ def test_payment_method_enum_values(): async def test_create_item( receipt_service: ReceiptService, mock_session: AsyncMock ) -> None: - """Test creating a receipt item and updating receipt total.""" + """Test creating a receipt item without changing receipt total.""" # Arrange existing_item = ReceiptItem( id=2, @@ -549,8 +554,7 @@ async def test_create_item( ) # Assert - # Total should be original (10.00) + new item total (2 * 5.50 = 11.00) = 21.00 - assert updated_receipt.total_amount == Decimal("21.00") + assert updated_receipt.total_amount == Decimal("10.00") mock_session.add.assert_called() mock_session.flush.assert_called_once() @@ -582,7 +586,7 @@ async def test_create_item_nonexistent_receipt( async def test_delete_item( receipt_service: ReceiptService, mock_session: AsyncMock ) -> None: - """Test deleting a receipt item and updating receipt total.""" + """Test deleting a receipt item without changing receipt total.""" # Arrange item = ReceiptItem( id=1, @@ -621,10 +625,9 @@ async def test_delete_item( ) # Assert - # Total should be original (15.00) - deleted item (5.00) = 10.00 - assert updated_receipt.total_amount == Decimal("10.00") + assert updated_receipt.total_amount == Decimal("15.00") mock_session.delete.assert_called_once_with(item) - assert mock_session.flush.call_count == 2 + assert mock_session.flush.call_count == 1 @pytest.mark.asyncio @@ -794,3 +797,186 @@ async def test_list_receipts_with_no_filters( # Assert assert len(result) == 3 mock_session.exec.assert_called_once() + + +def test_dedupe_scanned_items_by_total_removes_duplicate_lines( + receipt_service: ReceiptService, +) -> None: + """Auto-dedupe should remove duplicate-looking OCR lines when sums mismatch.""" + # This mirrors the Tesco-style duplicated line extraction issue. + amounts = [ + Decimal("2.10"), + Decimal("1.80"), + Decimal("1.80"), + Decimal("10.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("4.60"), + Decimal("2.10"), + Decimal("1.80"), + Decimal("1.80"), + Decimal("10.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("4.60"), + ] + names = [ + "TAPE MEASURE", + "PROTEIN COOKIE", + "PROTEIN COOKIE", + "CONDOMS", + "PROCECCO", + "PROCECCO", + "PROCECCO", + "SAUVIGNON BLNC", + "SAUVIGNON BLNC", + "KRONENBOURG", + "TAPE MEASURE", + "PROTEIN COOKIE", + "PROTEIN COOKIE", + "CONDOMS", + "SAUVIGNON BLNC", + "SAUVIGNON BLNC", + "KRONENBOURG", + ] + items = cast( + list[ReceiptItem], + [ + SimpleNamespace(name=name, total_price=amount, currency="GBP") + for name, amount in zip(names, amounts, strict=True) + ], + ) + + filtered, removed, note = receipt_service._dedupe_scanned_items_by_total( + items, Decimal("55.30") + ) + + assert len(filtered) < len(items) + assert len(removed) > 0 + assert sum(item.total_price for item in filtered) == Decimal("55.30") + assert note is not None + + +def test_dedupe_scanned_items_by_total_keeps_unique_lines( + receipt_service: ReceiptService, +) -> None: + """Auto-dedupe should not remove unique lines, even if a subset matches.""" + items = cast( + list[ReceiptItem], + [ + SimpleNamespace(name="A", total_price=Decimal("5.00"), currency="GBP"), + SimpleNamespace(name="B", total_price=Decimal("4.00"), currency="GBP"), + SimpleNamespace(name="C", total_price=Decimal("3.00"), currency="GBP"), + ], + ) + + filtered, removed, note = receipt_service._dedupe_scanned_items_by_total( + items, Decimal("9.00") + ) + + assert len(filtered) == len(items) + assert len(removed) == 0 + assert note is None + + +@pytest.mark.asyncio +async def test_reconcile_items_uses_deterministic_fallback_for_inconsistent_ai( + receipt_service: ReceiptService, + mock_session: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """When AI returns no actions despite mismatch, fallback should suggest removals.""" + amounts = [ + Decimal("2.10"), + Decimal("1.80"), + Decimal("1.80"), + Decimal("10.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("4.60"), + Decimal("2.10"), + Decimal("1.80"), + Decimal("1.80"), + Decimal("10.00"), + Decimal("7.00"), + Decimal("7.00"), + Decimal("4.60"), + ] + names = [ + "TAPE MEASURE", + "PROTEIN COOKIE", + "PROTEIN COOKIE", + "CONDOMS", + "PROCECCO", + "PROCECCO", + "PROCECCO", + "SAUVIGNON BLNC", + "SAUVIGNON BLNC", + "KRONENBOURG", + "TAPE MEASURE", + "PROTEIN COOKIE", + "PROTEIN COOKIE", + "CONDOMS", + "SAUVIGNON BLNC", + "SAUVIGNON BLNC", + "KRONENBOURG", + ] + items = [ + ReceiptItem( + id=index, + name=name, + quantity=1, + unit_price=amount, + total_price=amount, + currency="GBP", + receipt_id=1, + ) + for index, (name, amount) in enumerate(zip(names, amounts, strict=True), start=1) + ] + receipt_image = tmp_path / "receipt.png" + Image.new("RGB", (10, 10), "white").save(receipt_image) + receipt = Receipt( + id=1, + store_name="Tesco", + total_amount=Decimal("55.30"), + currency="GBP", + image_path=str(receipt_image), + items=items, + ) + + mock_session.scalar.return_value = receipt + mock_session.refresh = AsyncMock() + + async def fake_analyze_reconciliation( + image: object, receipt_total: str, items: list[dict[str, object]] + ) -> SimpleNamespace: + del image, receipt_total, items + return SimpleNamespace( + adjustments=[], + notes=( + "The provided items exactly match the receipt image, and no " + "adjustments are necessary." + ), + ) + + monkeypatch.setattr( + "app.receipt.services.analyze_reconciliation", + fake_analyze_reconciliation, + ) + monkeypatch.setattr(receipt_service, "resolve_image_path", lambda _: receipt_image) + + suggestion = await receipt_service.reconcile_items(receipt_id=1, user_id=TEST_USER_ID) + + assert suggestion.difference == Decimal("34.30") + assert suggestion.remaining_difference == Decimal("0") + assert len(suggestion.adjustments) > 0 + assert all(adjustment.remove for adjustment in suggestion.adjustments) + assert suggestion.notes is not None + assert "deterministic duplicate-line fallback" in suggestion.notes From e687a079319701028cf7089b5b02c5409c914e7b Mon Sep 17 00:00:00 2001 From: Cesar Goncalves Date: Sun, 8 Feb 2026 02:36:56 +0000 Subject: [PATCH 09/10] feat(frontend): add mismatch reconciliation flow and API wiring --- frontend/src/app/(app)/receipts/[id]/page.tsx | 369 ++++++++++++- frontend/src/components/scan/scan-result.tsx | 511 +++++++++++++++++- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/use-receipts.ts | 15 +- frontend/src/lib/api/client.ts | 7 + frontend/src/types/index.ts | 27 + 6 files changed, 904 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/(app)/receipts/[id]/page.tsx b/frontend/src/app/(app)/receipts/[id]/page.tsx index 2725bd4..81eb071 100644 --- a/frontend/src/app/(app)/receipts/[id]/page.tsx +++ b/frontend/src/app/(app)/receipts/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { use, useState } from "react"; +import { use, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -48,12 +48,24 @@ import { DollarSign, Plus, } from "lucide-react"; -import { useReceipt, useDeleteReceipt, useUpdateReceipt, useUpdateReceiptItem, useDeleteReceiptItem, useCategories } from "@/hooks"; +import { + useReceipt, + useDeleteReceipt, + useUpdateReceipt, + useUpdateReceiptItem, + useDeleteReceiptItem, + useReconcileReceipt, + useCategories, +} from "@/hooks"; import { AddItemDialog } from "@/components/receipts/add-item-dialog"; import { formatCurrency, formatDate } from "@/lib/format"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; -import type { ReceiptItem, ReceiptUpdate } from "@/types"; +import type { + ReceiptItem, + ReceiptReconcileSuggestion, + ReceiptUpdate, +} from "@/types"; import { PAYMENT_METHOD_LABELS } from "@/types"; import { MetadataForm } from "@/components/receipts/metadata-form"; @@ -61,6 +73,9 @@ interface PageProps { params: Promise<{ id: string }>; } +const roundCurrency = (value: number) => + Math.round((value + Number.EPSILON) * 100) / 100; + export default function ReceiptDetailPage({ params }: PageProps) { const { id } = use(params); const receiptId = parseInt(id, 10); @@ -71,6 +86,7 @@ export default function ReceiptDetailPage({ params }: PageProps) { const deleteMutation = useDeleteReceipt(); const updateMutation = useUpdateReceipt(); const updateItemMutation = useUpdateReceiptItem(); + const reconcileMutation = useReconcileReceipt(); const deleteItemMutation = useDeleteReceiptItem(); // Create a map for quick category lookup @@ -82,21 +98,91 @@ export default function ReceiptDetailPage({ params }: PageProps) { const [metadataDialogOpen, setMetadataDialogOpen] = useState(false); const [addItemDialogOpen, setAddItemDialogOpen] = useState(false); const [imagePreviewOpen, setImagePreviewOpen] = useState(false); + const [reconcileDialogOpen, setReconcileDialogOpen] = useState(false); + const [reconcileSuggestion, setReconcileSuggestion] = + useState(null); + const [aiAnalyzedFingerprint, setAiAnalyzedFingerprint] = useState< + string | null + >(null); + const [isReconciling, setIsReconciling] = useState(false); const [editingItem, setEditingItem] = useState(null); const [itemToDelete, setItemToDelete] = useState(null); const [isDeleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [editName, setEditName] = useState(""); const [editCategoryId, setEditCategoryId] = useState(""); + const [editQuantity, setEditQuantity] = useState(""); + const [editUnitPrice, setEditUnitPrice] = useState(""); + + const { receiptTotal, itemsTotal, delta, hasMismatch } = useMemo(() => { + if (!receipt) { + return { receiptTotal: 0, itemsTotal: 0, delta: 0, hasMismatch: false }; + } + + const receiptValue = roundCurrency(Number(receipt.total_amount)); + const itemsValue = roundCurrency( + receipt.items.reduce((sum, item) => sum + Number(item.total_price), 0) + ); + const difference = roundCurrency(itemsValue - receiptValue); + return { + receiptTotal: receiptValue, + itemsTotal: itemsValue, + delta: difference, + hasMismatch: Math.abs(difference) > 0.05, + }; + }, [receipt]); + + const itemById = useMemo(() => { + return new Map(receipt?.items.map((item) => [item.id, item]) ?? []); + }, [receipt]); + + const receiptStateFingerprint = useMemo(() => { + if (!receipt) return ""; + const itemsSignature = [...receipt.items] + .sort((a, b) => a.id - b.id) + .map( + (item) => + `${item.id}:${item.quantity}:${Number(item.unit_price)}:${Number(item.total_price)}` + ) + .join("|"); + return `${receipt.id}:${Number(receipt.total_amount)}:${itemsSignature}`; + }, [receipt]); + + const isAiAlreadyAnalyzed = + aiAnalyzedFingerprint !== null && aiAnalyzedFingerprint === receiptStateFingerprint; + useEffect(() => { + if (aiAnalyzedFingerprint && aiAnalyzedFingerprint !== receiptStateFingerprint) { + setReconcileSuggestion(null); + } + }, [aiAnalyzedFingerprint, receiptStateFingerprint]); const openEditItem = (item: ReceiptItem) => { setEditingItem(item); setEditName(item.name); setEditCategoryId(item.category_id?.toString() ?? ""); + setEditQuantity(item.quantity ?? item.quantity === 0 ? String(item.quantity) : ""); + setEditUnitPrice(item.unit_price ?? item.unit_price === 0 ? String(item.unit_price) : ""); }; const handleUpdateItem = async () => { if (!editingItem) return; + const quantityValue = editQuantity.trim(); + const unitPriceValue = editUnitPrice.trim(); + const quantity = + quantityValue === "" ? undefined : Number.parseInt(quantityValue, 10); + const unitPrice = + unitPriceValue === "" ? undefined : Number.parseFloat(unitPriceValue); + + if (quantityValue !== "" && Number.isNaN(quantity)) { + toast.error("Quantity must be a whole number"); + return; + } + + if (unitPriceValue !== "" && Number.isNaN(unitPrice)) { + toast.error("Unit price must be a number"); + return; + } + try { await updateItemMutation.mutateAsync({ receiptId, @@ -104,6 +190,8 @@ export default function ReceiptDetailPage({ params }: PageProps) { data: { name: editName.trim() || undefined, category_id: editCategoryId ? parseInt(editCategoryId) : undefined, + quantity, + unit_price: unitPrice, }, }); toast.success("Item updated"); @@ -116,6 +204,72 @@ export default function ReceiptDetailPage({ params }: PageProps) { } }; + const handleRunAiReconcile = async () => { + if (isAiAlreadyAnalyzed) { + return; + } + try { + const suggestion = await reconcileMutation.mutateAsync(receiptId); + setReconcileSuggestion(suggestion); + setAiAnalyzedFingerprint(receiptStateFingerprint); + if (suggestion.adjustments.length === 0) { + toast.info("AI did not find any safe adjustments"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to run AI reconcile" + ); + reconcileMutation.reset(); + } + }; + + const handleApplyAiSuggestions = async () => { + if (!reconcileSuggestion || reconcileSuggestion.adjustments.length === 0) { + toast.error("No AI adjustments to apply"); + return; + } + if (!receipt) { + toast.error("Receipt data not loaded"); + return; + } + + setIsReconciling(true); + try { + for (const adjustment of reconcileSuggestion.adjustments) { + await deleteItemMutation.mutateAsync({ + receiptId, + itemId: adjustment.item_id, + }); + } + toast.success("Applied AI adjustments"); + setReconcileDialogOpen(false); + setReconcileSuggestion(null); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to apply AI adjustments" + ); + deleteItemMutation.reset(); + } finally { + setIsReconciling(false); + } + }; + + const handleSetReceiptTotal = async () => { + try { + await updateMutation.mutateAsync({ + id: receiptId, + data: { total_amount: itemsTotal }, + }); + toast.success("Receipt total updated"); + setReconcileDialogOpen(false); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update receipt total" + ); + updateMutation.reset(); + } + }; + const handleUpdateMetadata = async (data: ReceiptUpdate) => { try { await updateMutation.mutateAsync({ id: receiptId, data }); @@ -225,8 +379,18 @@ export default function ReceiptDetailPage({ params }: PageProps) {

- {formatCurrency(Number(receipt.total_amount), receipt.currency)} + {formatCurrency(receiptTotal, receipt.currency)}

+ {hasMismatch && ( +
+ + Items don't match + +
+ )}
@@ -305,9 +469,50 @@ export default function ReceiptDetailPage({ params }: PageProps) { -
- Total - {formatCurrency(Number(receipt.total_amount), receipt.currency)} +
+
+
+ Receipt total + {hasMismatch && ( + + Items don't match + + )} +
+ + {formatCurrency(receiptTotal, receipt.currency)} + +
+ + {hasMismatch && ( + <> +
+ Items total + + {formatCurrency(itemsTotal, receipt.currency)} + +
+
+ Difference + + {(delta >= 0 ? "+" : "-") + + formatCurrency(Math.abs(delta), receipt.currency)} + +
+
+ +
+ + )}
@@ -492,6 +697,30 @@ export default function ReceiptDetailPage({ params }: PageProps) {
+
+
+ + setEditQuantity(e.target.value)} + /> +
+
+ + setEditUnitPrice(e.target.value)} + /> +
+
+ )} +
+ {reconcileSuggestion && ( +
+ {reconcileSuggestion.adjustments.length === 0 ? ( +

No removable lines suggested.

+ ) : ( +
+

+ Suggested removals: {reconcileSuggestion.adjustments.length} line + {reconcileSuggestion.adjustments.length > 1 ? "s" : ""}: +

+ {reconcileSuggestion.adjustments.map((adjustment) => { + const item = itemById.get(adjustment.item_id); + if (!item) return null; + + return ( +
+
+ {item.name} +
+ {adjustment.reason && ( +
{adjustment.reason}
+ )} +
+ ); + })} +
+ +
+
+ )} +
+ )} + + +
+
+
+

Set receipt total to items sum

+

+ Keeps item lines unchanged and updates only the receipt total. +

+
+ +
+
+ + + + + {/* Image Preview Dialog */} {receipt.image_path && ( diff --git a/frontend/src/components/scan/scan-result.tsx b/frontend/src/components/scan/scan-result.tsx index 5e801f6..1f87d64 100644 --- a/frontend/src/components/scan/scan-result.tsx +++ b/frontend/src/components/scan/scan-result.tsx @@ -1,14 +1,34 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; -import { Receipt, Check, ArrowRight } from "lucide-react"; +import { Receipt, Check, ArrowRight, Loader2, Trash2 } from "lucide-react"; import Link from "next/link"; -import type { Receipt as ReceiptType } from "@/types"; +import type { + Receipt as ReceiptType, + ReceiptItemCreate, + ReceiptReconcileSuggestion, + ScanRemovedItem, +} from "@/types"; import { formatCurrency, formatDate } from "@/lib/format"; -import { useCategories } from "@/hooks"; +import { toast } from "sonner"; +import { + useCategories, + useCreateReceiptItem, + useDeleteReceiptItem, + useUpdateReceipt, + useReconcileReceipt, +} from "@/hooks"; interface ScanResultProps { receipt: ReceiptType; @@ -17,12 +37,201 @@ interface ScanResultProps { export function ScanResult({ receipt, onScanAnother }: ScanResultProps) { const { data: categories } = useCategories(); + const updateReceiptMutation = useUpdateReceipt(); + const createItemMutation = useCreateReceiptItem(); + const deleteItemMutation = useDeleteReceiptItem(); + const reconcileMutation = useReconcileReceipt(); + const [currentReceipt, setCurrentReceipt] = useState(receipt); + const [reconcileDialogOpen, setReconcileDialogOpen] = useState(false); + const [reconcileSuggestion, setReconcileSuggestion] = + useState(null); + const [aiAnalyzedFingerprint, setAiAnalyzedFingerprint] = useState< + string | null + >(null); + const [isReconciling, setIsReconciling] = useState(false); + const [removingItemId, setRemovingItemId] = useState(null); + const [isRestoringRemoved, setIsRestoringRemoved] = useState(false); + const [removedDetailsOpen, setRemovedDetailsOpen] = useState(false); + const [autoRemovedItems, setAutoRemovedItems] = useState( + receipt.scan_removed_items ?? [] + ); // Create a map for quick category lookup - const categoryMap = new Map( - categories?.map((c) => [c.id, c.name]) ?? [] + const categoryMap = useMemo( + () => new Map(categories?.map((c) => [c.id, c.name]) ?? []), + [categories] + ); + + const itemById = useMemo( + () => new Map(currentReceipt.items.map((item) => [item.id, item])), + [currentReceipt] ); + const receiptStateFingerprint = useMemo(() => { + const itemsSignature = [...currentReceipt.items] + .sort((a, b) => a.id - b.id) + .map( + (item) => + `${item.id}:${item.quantity}:${Number(item.unit_price)}:${Number(item.total_price)}` + ) + .join("|"); + return `${currentReceipt.id}:${Number(currentReceipt.total_amount)}:${itemsSignature}`; + }, [currentReceipt]); + + const isAiAlreadyAnalyzed = aiAnalyzedFingerprint === receiptStateFingerprint; + useEffect(() => { + setCurrentReceipt(receipt); + setAutoRemovedItems(receipt.scan_removed_items ?? []); + if (receipt.id !== currentReceipt.id) { + setAiAnalyzedFingerprint(null); + setReconcileSuggestion(null); + } + }, [receipt, currentReceipt.id]); + + useEffect(() => { + if (aiAnalyzedFingerprint && aiAnalyzedFingerprint !== receiptStateFingerprint) { + setReconcileSuggestion(null); + } + }, [aiAnalyzedFingerprint, receiptStateFingerprint]); + + const roundCurrency = (value: number) => + Math.round((value + Number.EPSILON) * 100) / 100; + + const { receiptTotal, itemsTotal, delta, hasMismatch } = useMemo(() => { + const receiptValue = roundCurrency(Number(currentReceipt.total_amount)); + const itemsValue = roundCurrency( + currentReceipt.items.reduce((sum, item) => sum + Number(item.total_price), 0) + ); + const difference = roundCurrency(itemsValue - receiptValue); + return { + receiptTotal: receiptValue, + itemsTotal: itemsValue, + delta: difference, + hasMismatch: Math.abs(difference) > 0.05, + }; + }, [currentReceipt]); + + + const handleRunAiReconcile = async () => { + if (isAiAlreadyAnalyzed) { + return; + } + try { + const suggestion = await reconcileMutation.mutateAsync(currentReceipt.id); + setReconcileSuggestion(suggestion); + setAiAnalyzedFingerprint(receiptStateFingerprint); + if (suggestion.adjustments.length === 0) { + toast.info("AI did not find any safe adjustments"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to run AI reconcile" + ); + reconcileMutation.reset(); + } + }; + + const handleApplyAiSuggestions = async () => { + if (!reconcileSuggestion || reconcileSuggestion.adjustments.length === 0) { + toast.error("No AI adjustments to apply"); + return; + } + + setIsReconciling(true); + try { + let updatedReceipt = currentReceipt; + for (const adjustment of reconcileSuggestion.adjustments) { + updatedReceipt = await deleteItemMutation.mutateAsync({ + receiptId: currentReceipt.id, + itemId: adjustment.item_id, + }); + } + setCurrentReceipt(updatedReceipt); + toast.success("Applied AI adjustments"); + setReconcileDialogOpen(false); + setReconcileSuggestion(null); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to apply AI adjustments" + ); + deleteItemMutation.reset(); + } finally { + setIsReconciling(false); + } + }; + + const handleRemoveItem = async (itemId: number) => { + setRemovingItemId(itemId); + try { + const updatedReceipt = await deleteItemMutation.mutateAsync({ + receiptId: currentReceipt.id, + itemId, + }); + setCurrentReceipt(updatedReceipt); + toast.success("Item removed"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to remove item" + ); + deleteItemMutation.reset(); + } finally { + setRemovingItemId(null); + } + }; + + const handleSetReceiptTotal = async () => { + try { + const updatedReceipt = await updateReceiptMutation.mutateAsync({ + id: currentReceipt.id, + data: { total_amount: itemsTotal }, + }); + setCurrentReceipt(updatedReceipt); + toast.success("Receipt total updated"); + setReconcileDialogOpen(false); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update receipt total" + ); + updateReceiptMutation.reset(); + } + }; + + const handleUndoAutoRemoved = async () => { + if (autoRemovedItems.length === 0) { + return; + } + + setIsRestoringRemoved(true); + try { + let updatedReceipt = currentReceipt; + for (const removed of autoRemovedItems) { + const itemData: ReceiptItemCreate = { + name: removed.name, + quantity: removed.quantity, + unit_price: Number(removed.unit_price), + currency: removed.currency, + category_id: removed.category_id ?? undefined, + }; + + updatedReceipt = await createItemMutation.mutateAsync({ + receiptId: currentReceipt.id, + data: itemData, + }); + } + setCurrentReceipt(updatedReceipt); + setAutoRemovedItems([]); + setRemovedDetailsOpen(false); + toast.success("Restored auto-removed items"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to restore items" + ); + createItemMutation.reset(); + } finally { + setIsRestoringRemoved(false); + } + }; + return (
{/* Success Banner */} @@ -33,8 +242,45 @@ export function ScanResult({ receipt, onScanAnother }: ScanResultProps) {

Receipt scanned successfully!

- {receipt.items.length} items detected + {currentReceipt.items.length} items detected

+ {autoRemovedItems.length > 0 && ( +
+ + Auto-removed {autoRemovedItems.length} suspected duplicate line + {autoRemovedItems.length > 1 ? "s" : ""} + + + +
+ )} + {hasMismatch && ( +

+ Items don't match total by{" "} + {(delta >= 0 ? "+" : "-") + + formatCurrency(Math.abs(delta), currentReceipt.currency)} +

+ )}
@@ -47,16 +293,26 @@ export function ScanResult({ receipt, onScanAnother }: ScanResultProps) {
- {receipt.store_name} + {currentReceipt.store_name}

- {formatDate(receipt.purchase_date)} + {formatDate(currentReceipt.purchase_date)}

- {formatCurrency(Number(receipt.total_amount), receipt.currency)} + {formatCurrency(receiptTotal, currentReceipt.currency)}

+ {hasMismatch && ( +
+ + Items don't match + +
+ )}
@@ -65,7 +321,7 @@ export function ScanResult({ receipt, onScanAnother }: ScanResultProps) { {/* Items */}
- {receipt.items.map((item) => { + {currentReceipt.items.map((item) => { const categoryName = item.category_id ? categoryMap.get(item.category_id) : null; @@ -84,9 +340,26 @@ export function ScanResult({ receipt, onScanAnother }: ScanResultProps) { )}
- - {formatCurrency(Number(item.total_price), item.currency)} - +
+ + {formatCurrency(Number(item.total_price), item.currency)} + + +
); })} @@ -94,10 +367,46 @@ export function ScanResult({ receipt, onScanAnother }: ScanResultProps) { - {/* Total */} -
- Total - {formatCurrency(Number(receipt.total_amount), receipt.currency)} +
+
+
+ Receipt total + {hasMismatch && ( + + Items don't match + + )} +
+ {formatCurrency(receiptTotal, currentReceipt.currency)} +
+ + {hasMismatch && ( + <> +
+ Items total + {formatCurrency(itemsTotal, currentReceipt.currency)} +
+
+ Difference + + {(delta >= 0 ? "+" : "-") + + formatCurrency(Math.abs(delta), currentReceipt.currency)} + +
+
+ +
+ + )}
@@ -108,12 +417,178 @@ export function ScanResult({ receipt, onScanAnother }: ScanResultProps) { Scan Another
+ + + + + Fix total mismatch + + Items don't match the receipt total. Choose how to reconcile. + + +
+
+
+ Receipt total + {formatCurrency(receiptTotal, currentReceipt.currency)} +
+
+ Items total + {formatCurrency(itemsTotal, currentReceipt.currency)} +
+
+ Difference + + {(delta >= 0 ? "+" : "-") + + formatCurrency(Math.abs(delta), currentReceipt.currency)} + +
+
+ +
+
+
+
+

AI reconcile items

+

+ Removes likely duplicate/noise item lines. +

+
+ {reconcileSuggestion === null && ( + + )} +
+ {reconcileSuggestion && ( +
+ {reconcileSuggestion.adjustments.length === 0 ? ( +

No removable lines suggested.

+ ) : ( +
+

+ Suggested removals: {reconcileSuggestion.adjustments.length} line + {reconcileSuggestion.adjustments.length > 1 ? "s" : ""}: +

+ {reconcileSuggestion.adjustments.map((adjustment) => { + const item = itemById.get(adjustment.item_id); + if (!item) return null; + + return ( +
+
+ {item.name} +
+ {adjustment.reason && ( +
{adjustment.reason}
+ )} +
+ ); + })} +
+ +
+
+ )} +
+ )} +
+ +
+
+
+

Set receipt total to items sum

+

+ Keeps item lines unchanged and updates only the receipt total. +

+
+ +
+
+
+
+
+
+ + + + + Auto-removed duplicate lines + + These lines were auto-removed because item totals exceeded the + receipt total and duplicates were detected. + + +
+ {autoRemovedItems.length === 0 ? ( +

+ No auto-removed lines. +

+ ) : ( + autoRemovedItems.map((item, index) => ( +
+ + {item.quantity}x {item.name} + + + {formatCurrency(Number(item.total_price), item.currency)} + +
+ )) + )} +
+ {autoRemovedItems.length > 0 && ( +
+ +
+ )} +
+
); } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index b94c1bc..5287195 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -6,6 +6,7 @@ export { useExportReceipts, useExportReceiptsPdf, useUpdateReceipt, + useReconcileReceipt, useDeleteReceipt, useUpdateReceiptItem, useCreateReceiptItem, diff --git a/frontend/src/hooks/use-receipts.ts b/frontend/src/hooks/use-receipts.ts index 19ccc85..0925a9e 100644 --- a/frontend/src/hooks/use-receipts.ts +++ b/frontend/src/hooks/use-receipts.ts @@ -2,7 +2,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; -import type { Receipt, ReceiptUpdate, ReceiptItemCreate, ReceiptItemUpdate, ReceiptFilters } from "@/types"; +import type { + Receipt, + ReceiptFilters, + ReceiptItemCreate, + ReceiptItemUpdate, + ReceiptReconcileSuggestion, + ReceiptUpdate, +} from "@/types"; const RECEIPTS_KEY = ["receipts"]; const STORES_KEY = ["receipts", "stores"]; @@ -124,6 +131,12 @@ export function useUpdateReceipt() { }); } +export function useReconcileReceipt() { + return useMutation({ + mutationFn: (receiptId: number) => api.reconcileReceipt(receiptId), + }); +} + export function useDeleteReceipt() { const queryClient = useQueryClient(); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 651e71e..b4afddc 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -6,6 +6,7 @@ import type { CategoryUpdate, LoginCredentials, Receipt, + ReceiptReconcileSuggestion, ReceiptUpdate, ReceiptItemCreate, ReceiptItemUpdate, @@ -182,6 +183,12 @@ class ApiClient { }); } + async reconcileReceipt(id: number): Promise { + return this.request(`/receipts/${id}/reconcile`, { + method: "POST", + }); + } + async deleteReceipt(id: number): Promise { await this.request(`/receipts/${id}`, { method: "DELETE", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 91408c8..e4d1999 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -65,6 +65,15 @@ export interface ReceiptItemCreate { category_id?: number | null; } +export interface ScanRemovedItem { + name: string; + quantity: number; + unit_price: number; + total_price: number; + currency: string; + category_id?: number | null; +} + export interface Receipt { id: number; store_name: string; @@ -80,6 +89,7 @@ export interface Receipt { tags: string[]; payment_method: PaymentMethod | null; tax_amount: number | null; + scan_removed_items?: ScanRemovedItem[] | null; } export interface ReceiptUpdate { @@ -94,6 +104,23 @@ export interface ReceiptUpdate { tax_amount?: number | null; } +export interface ReceiptReconcileAdjustment { + item_id: number; + remove: true; + reason?: string | null; +} + +export interface ReceiptReconcileSuggestion { + receipt_id: number; + receipt_total: number; + items_total: number; + difference: number; + adjusted_items_total: number; + remaining_difference: number; + adjustments: ReceiptReconcileAdjustment[]; + notes?: string | null; +} + // Scan result from AI analysis export interface ScanResult { receipt: Receipt; From 2425774005be890d1c4cca51422ff200ec5f3216 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 8 Feb 2026 02:37:49 +0000 Subject: [PATCH 10/10] Apply pre-commit hook fixes --- .../pydantic_ai/receipt_reconcile_agent.py | 4 +-- backend/app/receipt/services.py | 31 +++++++++++-------- .../unit/receipt/test_receipt_service.py | 8 +++-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py b/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py index 8363a43..085eb73 100644 --- a/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py +++ b/backend/app/integrations/pydantic_ai/receipt_reconcile_agent.py @@ -143,6 +143,4 @@ async def analyze_reconciliation( return result.output except Exception as e: - raise ServiceUnavailableError( - f"Error reconciling receipt: {str(e)}" - ) from e + raise ServiceUnavailableError(f"Error reconciling receipt: {str(e)}") from e diff --git a/backend/app/receipt/services.py b/backend/app/receipt/services.py index bef0355..a1f1831 100644 --- a/backend/app/receipt/services.py +++ b/backend/app/receipt/services.py @@ -97,9 +97,7 @@ def _find_subset_indices_matching_total( 2. Preferring earlier lines when counts tie """ n_items = len(item_totals_cents) - best_by_sum: dict[int, tuple[int, int, tuple[int, ...]]] = { - 0: (0, 0, ()) - } + best_by_sum: dict[int, tuple[int, int, tuple[int, ...]]] = {0: (0, 0, ())} for idx, amount_cents in enumerate(item_totals_cents): existing_states = list(best_by_sum.items()) @@ -191,7 +189,9 @@ def _fallback_duplicate_removal_adjustments( self, items: ReceiptItemList, expected_total: Decimal ) -> tuple[ReceiptItemAdjustmentList, str | None]: """Build deterministic remove-adjustments when AI returns no actionable output.""" - _, removed_items, note = self._dedupe_scanned_items_by_total(items, expected_total) + _, removed_items, note = self._dedupe_scanned_items_by_total( + items, expected_total + ) adjustments: ReceiptItemAdjustmentList = [] for item in removed_items: if item.id is None: @@ -248,7 +248,9 @@ async def create(self, receipt_in: ReceiptCreate, user_id: int) -> Receipt: await self.session.flush() return receipt - async def create_from_scan(self, image_file: UploadFile, user_id: int) -> ReceiptRead: + async def create_from_scan( + self, image_file: UploadFile, user_id: int + ) -> ReceiptRead: """Create a receipt from an uploaded image file. This method: @@ -360,8 +362,8 @@ async def create_from_scan(self, image_file: UploadFile, user_id: int) -> Receip receipt_items.append(receipt_item) # Add items to database - receipt_items, removed_items, auto_note = self._dedupe_scanned_items_by_total( - receipt_items, receipt.total_amount + receipt_items, removed_items, auto_note = ( + self._dedupe_scanned_items_by_total(receipt_items, receipt.total_amount) ) if auto_note: receipt.notes = ( @@ -617,9 +619,7 @@ async def reconcile_items( notes: list[str] = [] - valid_item_ids = { - item.id for item in receipt.items if item.id is not None - } + valid_item_ids = {item.id for item in receipt.items if item.id is not None} adjustments_by_id: dict[int, ReceiptItemAdjustment] = {} for adjustment in analysis.adjustments: @@ -645,8 +645,10 @@ async def reconcile_items( ) if not adjustments_by_id and abs(difference) > Decimal("0.05"): - fallback_adjustments, fallback_note = self._fallback_duplicate_removal_adjustments( - list(receipt.items), receipt_total + fallback_adjustments, fallback_note = ( + self._fallback_duplicate_removal_adjustments( + list(receipt.items), receipt_total + ) ) if fallback_adjustments: notes.append( @@ -655,7 +657,10 @@ async def reconcile_items( if fallback_note: notes.append(fallback_note) adjustments_by_id.update( - {adjustment.item_id: adjustment for adjustment in fallback_adjustments} + { + adjustment.item_id: adjustment + for adjustment in fallback_adjustments + } ) # Validate adjustments and compute adjusted total diff --git a/backend/tests/unit/receipt/test_receipt_service.py b/backend/tests/unit/receipt/test_receipt_service.py index da80142..d02ce25 100644 --- a/backend/tests/unit/receipt/test_receipt_service.py +++ b/backend/tests/unit/receipt/test_receipt_service.py @@ -938,7 +938,9 @@ async def test_reconcile_items_uses_deterministic_fallback_for_inconsistent_ai( currency="GBP", receipt_id=1, ) - for index, (name, amount) in enumerate(zip(names, amounts, strict=True), start=1) + for index, (name, amount) in enumerate( + zip(names, amounts, strict=True), start=1 + ) ] receipt_image = tmp_path / "receipt.png" Image.new("RGB", (10, 10), "white").save(receipt_image) @@ -972,7 +974,9 @@ async def fake_analyze_reconciliation( ) monkeypatch.setattr(receipt_service, "resolve_image_path", lambda _: receipt_image) - suggestion = await receipt_service.reconcile_items(receipt_id=1, user_id=TEST_USER_ID) + suggestion = await receipt_service.reconcile_items( + receipt_id=1, user_id=TEST_USER_ID + ) assert suggestion.difference == Decimal("34.30") assert suggestion.remaining_difference == Decimal("0")