diff --git a/app/routes/transactions.py b/app/routes/transactions.py index 38de569..48f87ab 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -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, + }) diff --git a/app/services/ocr_service.py b/app/services/ocr_service.py new file mode 100644 index 0000000..6c52882 --- /dev/null +++ b/app/services/ocr_service.py @@ -0,0 +1,227 @@ +""" +OCR Service — extracts transaction data from receipt images using Groq vision. + +Model: meta-llama/llama-4-scout-17b-16e-instruct (multimodal) +Input: image file path or bytes +Output: dict with amount, date, merchant, category_suggestion, notes +""" + +import base64 +import json +import logging +import os +import re +from datetime import date, datetime +from flask import current_app + +log = logging.getLogger(__name__) + +GROQ_VISION_MODEL = 'meta-llama/llama-4-scout-17b-16e-instruct' +SUPPORTED_MIME_TYPES = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', +} + +RECEIPT_PROMPT = """You are a receipt data extractor. Analyze this receipt image and extract the following information. + +Respond ONLY with a valid JSON object, no explanation, no markdown, no code fences. Just raw JSON. + +Required format: +{ + "amount": , + "date": "", + "merchant": "", + "category_suggestion": "", + "notes": "" +} + +Rules: +- amount: use the TOTAL/GRAND TOTAL amount, not subtotal. Numbers only, no commas. +- date: format as YYYY-MM-DD. If only month/day visible, use current year. +- merchant: the business name only, no address. +- category_suggestion: pick the single most appropriate category from the list. +- notes: keep under 60 characters. +- If you cannot read the receipt clearly, still return your best guess with available data. +- Never return null for amount if any number is visible.""" + + +def extract_from_file(file_path): + """ + Extract receipt data from an image file on disk. + Returns dict or None on failure. + """ + ext = os.path.splitext(file_path)[1].lower() + mime_type = SUPPORTED_MIME_TYPES.get(ext) + + if not mime_type: + log.warning(f'[ocr] Unsupported file type: {ext}') + return _error_result(f'Unsupported file type: {ext}') + + try: + with open(file_path, 'rb') as f: + image_bytes = f.read() + return extract_from_bytes(image_bytes, mime_type) + except FileNotFoundError: + log.error(f'[ocr] File not found: {file_path}') + return _error_result('Receipt file not found') + except Exception as e: + log.error(f'[ocr] File read error: {e}') + return _error_result(str(e)) + + +def extract_from_bytes(image_bytes, mime_type='image/jpeg'): + """ + Extract receipt data from raw image bytes. + Returns dict with keys: amount, date, merchant, category_suggestion, notes, error + """ + api_key = current_app.config.get('GROQ_API_KEY', '') + if not api_key: + return _error_result('GROQ_API_KEY not configured') + + # Encode to base64 + b64 = base64.b64encode(image_bytes).decode('utf-8') + data_url = f'data:{mime_type};base64,{b64}' + + try: + import requests + resp = requests.post( + 'https://api.groq.com/openai/v1/chat/completions', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + json={ + 'model': GROQ_VISION_MODEL, + 'messages': [ + { + 'role': 'user', + 'content': [ + { + 'type': 'image_url', + 'image_url': {'url': data_url}, + }, + { + 'type': 'text', + 'text': RECEIPT_PROMPT, + }, + ], + } + ], + 'max_tokens': 512, + 'temperature': 0.1, # low temp for consistent structured output + }, + timeout=30, + ) + resp.raise_for_status() + content = resp.json()['choices'][0]['message']['content'].strip() + log.info(f'[ocr] Raw response: {content[:200]}') + return _parse_response(content) + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 400: + # Model may not support this image — try to parse error + try: + err_body = e.response.json() + msg = err_body.get('error', {}).get('message', str(e)) + except Exception: + msg = str(e) + log.error(f'[ocr] Groq 400 error: {msg}') + return _error_result(f'Image processing failed: {msg[:100]}') + elif e.response.status_code == 429: + return _error_result('Rate limit reached. Try again shortly.') + elif e.response.status_code == 401: + return _error_result('Invalid Groq API key.') + else: + log.error(f'[ocr] HTTP {e.response.status_code}: {e}') + return _error_result(f'API error ({e.response.status_code})') + except Exception as e: + log.error(f'[ocr] Unexpected error: {e}') + return _error_result('OCR service temporarily unavailable') + + +def _parse_response(content): + """Parse the JSON response from Groq into a clean dict.""" + # Strip any accidental markdown fences + content = re.sub(r'^```(?:json)?\s*', '', content, flags=re.MULTILINE) + content = re.sub(r'\s*```$', '', content, flags=re.MULTILINE) + content = content.strip() + + try: + data = json.loads(content) + except json.JSONDecodeError: + # Try to extract JSON object from surrounding text + match = re.search(r'\{.*\}', content, re.DOTALL) + if match: + try: + data = json.loads(match.group()) + except json.JSONDecodeError: + log.error(f'[ocr] Could not parse JSON: {content[:200]}') + return _error_result('Could not parse OCR response') + else: + return _error_result('No JSON found in OCR response') + + # Normalise amount + amount = data.get('amount') + if amount is not None: + try: + amount = float(str(amount).replace(',', '').strip()) + if amount <= 0: + amount = None + except (ValueError, TypeError): + amount = None + + # Normalise date + raw_date = data.get('date') + parsed_date = None + if raw_date: + for fmt in ('%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d'): + try: + parsed_date = datetime.strptime(str(raw_date).strip(), fmt).date().isoformat() + break + except ValueError: + continue + + # Map category suggestion to our system category names + cat_map = { + 'food & dining': 'Food & Dining', + 'food': 'Food & Dining', + 'dining': 'Food & Dining', + 'transport': 'Transport', + 'transportation':'Transport', + 'shopping': 'Shopping', + 'health': 'Health', + 'entertainment': 'Entertainment', + 'utilities': 'Utilities', + 'housing': 'Housing', + 'education': 'Education', + 'personal care': 'Personal Care', + 'travel': 'Travel', + 'other': 'Other', + } + raw_cat = str(data.get('category_suggestion', '')).lower().strip() + category = cat_map.get(raw_cat, data.get('category_suggestion', 'Other')) + + return { + 'amount': amount, + 'date': parsed_date or date.today().isoformat(), + 'merchant': data.get('merchant') or '', + 'category_suggestion': category, + 'notes': data.get('notes') or '', + 'error': None, + 'raw': data, + } + + +def _error_result(msg): + return { + 'amount': None, + 'date': date.today().isoformat(), + 'merchant': '', + 'category_suggestion': 'Other', + 'notes': '', + 'error': msg, + 'raw': {}, + } diff --git a/app/templates/base.html b/app/templates/base.html index 2f6a28a..c58ba20 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -156,6 +156,7 @@ .sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; } .sb-overlay.on { display: block; } + @keyframes spin { to { transform: rotate(360deg); } } {% block extra_css %}{% endblock %} diff --git a/app/templates/transactions/form.html b/app/templates/transactions/form.html index 8a7fd5c..f5eaab4 100644 --- a/app/templates/transactions/form.html +++ b/app/templates/transactions/form.html @@ -2,17 +2,70 @@ {% block title %}{{ title }}{% endblock %} {% block page_title %}{{ title }}{% endblock %} +{% block extra_css %} +#ocrDropZone { + border: 2px dashed var(--border); + border-radius: 10px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: all .2s; + background: #f8fafc; +} +#ocrDropZone.dragover { border-color: #3b82f6; background: #eff6ff; } +#ocrDropZone.loading { border-color: #818cf8; background: #f5f3ff; } +#ocrDropZone.success { border-color: #10b981; background: #f0fdf4; } +#ocrDropZone.error { border-color: #ef4444; background: #fef2f2; } +.ocr-field-highlight { animation: ocr-flash .6s ease; } +@keyframes ocr-flash { 0%,100%{background:transparent} 50%{background:#d1fae5} } +{% endblock %} + {% block content %}
-
+
+ + +
+
+
+ AI Receipt Scanner +
+ +
+
+
+ +
+ +
+ Drop receipt image here or click to upload +
+
+ JPG, PNG, GIF, WEBP · max 10MB · Powered by Groq vision +
+
+
+ +
+
+ +
-
+ {{ form.hidden_tag() }} {{ form.transaction_type() }}
{{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }} - {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), placeholder="What was this for?") }} + {{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), + placeholder="What was this for?", id="field_description") }} {% for e in form.description.errors %}
{{ e }}
{% endfor %}
@@ -21,13 +74,15 @@ {{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ current_user.currency_symbol }} - {{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00") }} + {{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), + placeholder="0.00", id="field_amount") }}
{% for e in form.amount.errors %}
{{ e }}
{% endfor %}
{{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }} - {{ form.date(class="form-control" + (" is-invalid" if form.date.errors else "")) }} + {{ form.date(class="form-control" + (" is-invalid" if form.date.errors else ""), + id="field_date") }}
@@ -39,15 +94,15 @@
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }} - {{ form.category_id(class="form-select") }} + {{ form.category_id(class="form-select", id="field_category") }}
-
+
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} - {{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }} + {{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
- + {% if txn and txn.receipt %}
@@ -57,22 +112,32 @@ {{ txn.receipt.original_filename }}
- - - - +
+ +
+ + +
+
{% elif txn %}
-
+
- +
- PNG, JPG, GIF, PDF — max 10MB + PNG, JPG, WEBP, PDF — max 10MB
{% endif %} @@ -88,3 +153,167 @@
{% endblock %} + +{% block extra_js %} + +{% endblock %}