05/31 Phase 7: receipt ocr

This commit is contained in:
2026-05-31 17:17:08 -04:00
parent d74b9e7543
commit 5983794217
4 changed files with 577 additions and 16 deletions
+104
View File
@@ -9,6 +9,7 @@ from app.models.account import Account
from app.models.category import Category
from app.services.account_service import calc_balance
from datetime import date, datetime
import os
from sqlalchemy import or_
transactions_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
@@ -220,3 +221,106 @@ def transfer():
return redirect(url_for('transactions.index'))
return render_template('transactions/transfer.html', form=form)
@transactions_bp.route('/ocr', methods=['POST'])
@login_required
def ocr_receipt():
"""
POST a receipt image, get back extracted transaction data as JSON.
Used by both new expense form and edit form.
"""
from app.services.ocr_service import extract_from_bytes
from app.models.category import Category
if 'receipt' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
f = request.files['receipt']
if not f.filename:
return jsonify({'error': 'Empty filename'}), 400
ext = os.path.splitext(f.filename)[1].lower()
allowed = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
if ext not in allowed:
return jsonify({'error': f'Unsupported type: {ext}. Use JPG, PNG, GIF, WEBP'}), 400
# Read bytes — limit 10MB
f.seek(0, 2)
size = f.tell()
f.seek(0)
if size > 10 * 1024 * 1024:
return jsonify({'error': 'File too large (max 10MB)'}), 400
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp'}
mime_type = mime_map.get(ext, 'image/jpeg')
image_bytes = f.read()
result = extract_from_bytes(image_bytes, mime_type)
if result['error']:
return jsonify({'error': result['error']}), 422
# Look up category ID from suggestion
category_id = None
if result['category_suggestion']:
cat = Category.query.filter(
Category.name.ilike(result['category_suggestion']),
Category.is_active == True,
).first()
if cat:
category_id = cat.id
return jsonify({
'amount': result['amount'],
'date': result['date'],
'description': result['merchant'] or result['notes'] or '',
'notes': result['notes'],
'category_suggestion': result['category_suggestion'],
'category_id': category_id,
})
@transactions_bp.route('/ocr-file', methods=['POST'])
@login_required
def ocr_receipt_file():
"""
Re-extract from an already-uploaded receipt file stored on disk.
Body: { "filename": "abc123.jpg" }
"""
from app.services.ocr_service import extract_from_file
from app.models.category import Category
from flask import current_app
data = request.get_json()
if not data or not data.get('filename'):
return jsonify({'error': 'No filename provided'}), 400
# Security: only allow basenames, no path traversal
filename = os.path.basename(data['filename'])
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
file_path = os.path.join(upload_dir, filename)
result = extract_from_file(file_path)
if result['error']:
return jsonify({'error': result['error']}), 422
category_id = None
if result['category_suggestion']:
cat = Category.query.filter(
Category.name.ilike(result['category_suggestion']),
Category.is_active == True,
).first()
if cat:
category_id = cat.id
return jsonify({
'amount': result['amount'],
'date': result['date'],
'description': result['merchant'] or result['notes'] or '',
'notes': result['notes'],
'category_suggestion': result['category_suggestion'],
'category_id': category_id,
})