Skip to content

Commit acf27e1

Browse files
Enhance Paperless integration with custom fields, storage paths, bulk sync
- Add custom fields support (Expense Amount, Reimbursed Amount, Claim Number, Status, Account Type, Family Member) for better organization in Paperless - Add storage paths matching HSA Tracker's folder structure (HSA-Tracker/{category}/{year}/receipts for receipts, HSA-Tracker/benefit_plans/{year} for policy documents) - Add bulk sync feature in settings to sync all unsynced documents at once - Add "View in Paperless" link on synced items - clicking the green checkmark opens the document directly in Paperless - Auto-update Paperless metadata when expense is edited (status, amounts, etc.) - Wait for Paperless document processing to complete before applying metadata 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent b02ecd7 commit acf27e1

File tree

8 files changed

+778
-17
lines changed

8 files changed

+778
-17
lines changed

app/app.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,20 +125,27 @@ def inject_user_preferences():
125125
currency_symbol = get_currency_symbol(currency)
126126
user_date_format = get_user_date_format(user_id)
127127

128-
# Check if user has completed onboarding
128+
# Check if user has completed onboarding and get Paperless config
129129
conn = get_db_connection()
130-
user = conn.execute('SELECT onboarding_completed FROM users WHERE id = ?', (user_id,)).fetchone()
130+
user = conn.execute('''SELECT onboarding_completed, paperless_url, paperless_enabled
131+
FROM users WHERE id = ?''', (user_id,)).fetchone()
131132
conn.close()
132133

133134
is_first_login = False
134135
if user and not user['onboarding_completed']:
135136
is_first_login = True
136137

138+
# Build Paperless config for client-side use
139+
paperless_config = None
140+
if user and user['paperless_enabled'] and user['paperless_url']:
141+
paperless_config = {'url': user['paperless_url'].rstrip('/')}
142+
137143
return dict(
138144
user_currency=currency,
139145
currency_symbol=currency_symbol,
140146
user_date_format=user_date_format,
141147
is_first_login=is_first_login,
148+
paperless_config=paperless_config,
142149
oidc_enabled=OIDC_ENABLED,
143150
oidc_allow_local_auth=ALLOW_LOCAL_AUTH,
144151
oidc_providers=get_oidc_providers_for_template()
@@ -148,6 +155,7 @@ def inject_user_preferences():
148155
currency_symbol='$',
149156
user_date_format=DEFAULT_DATE_FORMAT,
150157
is_first_login=False,
158+
paperless_config=None,
151159
oidc_enabled=OIDC_ENABLED,
152160
oidc_allow_local_auth=ALLOW_LOCAL_AUTH,
153161
oidc_providers=get_oidc_providers_for_template()

app/assets/js/services/expenses.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ export function createEditReceiptItem(attachment, expenseId, allAttachments = nu
590590
const previewAttachments = allAttachments || [attachment];
591591

592592
// Get sync status icon
593-
const syncIcon = getSyncStatusIcon(attachment.paperless_sync_status);
593+
const syncIcon = getSyncStatusIcon(attachment.paperless_sync_status, attachment.paperless_doc_id);
594594

595595
if (fileType === 'pdf') {
596596
const iconDiv = document.createElement('div');
@@ -1045,11 +1045,24 @@ export function viewExpenseDetails(id) {
10451045
}
10461046

10471047
// Helper function to create sync status icon HTML
1048-
function getSyncStatusIcon(syncStatus) {
1048+
function getSyncStatusIcon(syncStatus, paperlessDocId = null) {
10491049
if (!syncStatus) return ''; // Paperless not configured or not synced yet
10501050

1051+
// Get Paperless URL from global config (set by server)
1052+
const paperlessUrl = window.paperlessConfig?.url;
1053+
10511054
switch (syncStatus) {
10521055
case 'synced':
1056+
// If we have paperless URL and doc ID, make it a link
1057+
if (paperlessUrl && paperlessDocId) {
1058+
return `<a href="${paperlessUrl}/documents/${paperlessDocId}" target="_blank" rel="noopener"
1059+
class="sync-status sync-synced" title="View in Paperless (opens in new tab)">
1060+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1061+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
1062+
<polyline points="22 4 12 14.01 9 11.01"/>
1063+
</svg>
1064+
</a>`;
1065+
}
10531066
return `<span class="sync-status sync-synced" title="Synced to Paperless">
10541067
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
10551068
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@@ -1118,7 +1131,7 @@ function createAttachmentItem(attachment, expenseId, allAttachments = null, atta
11181131
contentDiv.title = 'Click to preview';
11191132

11201133
// Get sync status icon
1121-
const syncIcon = getSyncStatusIcon(attachment.paperless_sync_status);
1134+
const syncIcon = getSyncStatusIcon(attachment.paperless_sync_status, attachment.paperless_doc_id);
11221135

11231136
if (fileType === 'pdf') {
11241137
contentDiv.innerHTML = `

app/assets/js/services/policyYearDocuments.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,24 @@ import { showAlert } from '../components/alerts.js';
55
import { showConfirmModal } from '../components/confirmModal.js';
66

77
// Helper function to create sync status icon HTML
8-
function getSyncStatusIcon(syncStatus) {
8+
function getSyncStatusIcon(syncStatus, paperlessDocId = null) {
99
if (!syncStatus) return ''; // Paperless not configured or not synced yet
1010

11+
// Get Paperless URL from global config (set by server)
12+
const paperlessUrl = window.paperlessConfig?.url;
13+
1114
switch (syncStatus) {
1215
case 'synced':
16+
// If we have paperless URL and doc ID, make it a link
17+
if (paperlessUrl && paperlessDocId) {
18+
return `<a href="${paperlessUrl}/documents/${paperlessDocId}" target="_blank" rel="noopener"
19+
class="sync-status sync-synced" title="View in Paperless (opens in new tab)">
20+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
21+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
22+
<polyline points="22 4 12 14.01 9 11.01"/>
23+
</svg>
24+
</a>`;
25+
}
1326
return `<span class="sync-status sync-synced" title="Synced to Paperless">
1427
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1528
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@@ -125,7 +138,7 @@ function createPolicyDocumentItem(doc) {
125138
const filePath = doc.file_path.replace(/^data\/\d+\//, '');
126139

127140
// Get sync status icon
128-
const syncIcon = getSyncStatusIcon(doc.paperless_sync_status);
141+
const syncIcon = getSyncStatusIcon(doc.paperless_sync_status, doc.paperless_doc_id);
129142

130143
// Check if it's an image type
131144
const isImage = ['jpg', 'jpeg', 'png'].includes(fileType);

app/routes/expenses.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from flask import Blueprint, request, jsonify, session
88
from datetime import datetime
99
import os
10+
import logging
1011

11-
from database import get_db_connection
12+
from database import get_db_connection, db_readonly
1213
from utils.decorators import login_required
1314
from utils.helpers import get_user_receipts_folder, allowed_file, generate_attachment_filename, get_user_naming_patterns
1415
from services.expense_service import (
@@ -20,9 +21,59 @@
2021
CapacityResult,
2122
)
2223

24+
logger = logging.getLogger(__name__)
2325
expenses_bp = Blueprint('expenses', __name__)
2426

2527

28+
def _update_paperless_for_expense(user_id: int, expense_id: int, expense_data: dict) -> None:
29+
"""Update Paperless documents for all synced attachments of an expense.
30+
31+
Args:
32+
user_id: User ID
33+
expense_id: Expense ID
34+
expense_data: Dict with updated expense data (status, category, etc.)
35+
"""
36+
try:
37+
from services.paperless_service import update_document_in_paperless
38+
39+
# Get all synced attachments for this expense
40+
with db_readonly() as conn:
41+
attachments = conn.execute('''
42+
SELECT a.paperless_doc_id, e.status, e.category, e.claim_number,
43+
e.amount_outflow, e.amount_inflow, f.name as family_member
44+
FROM attachments a
45+
JOIN expenses e ON a.expense_id = e.id
46+
LEFT JOIN family_members f ON e.family_member_id = f.id
47+
WHERE a.expense_id = ? AND a.paperless_sync_status = 'synced'
48+
AND a.paperless_doc_id IS NOT NULL
49+
''', (expense_id,)).fetchall()
50+
51+
# Update each synced attachment in Paperless
52+
for attachment in attachments:
53+
metadata = {
54+
'status': expense_data.get('status', attachment['status']),
55+
'category': expense_data.get('category', attachment['category']),
56+
'claim_number': expense_data.get('claim_number', attachment['claim_number']),
57+
'amount_outflow': expense_data.get('amount_outflow', attachment['amount_outflow']),
58+
'amount_inflow': expense_data.get('amount_inflow', attachment['amount_inflow']),
59+
'family_member': attachment['family_member'],
60+
'doc_type': 'receipt'
61+
}
62+
63+
success, message = update_document_in_paperless(
64+
user_id=user_id,
65+
paperless_doc_id=attachment['paperless_doc_id'],
66+
metadata=metadata
67+
)
68+
69+
if not success:
70+
logger.warning(f"Failed to update Paperless document {attachment['paperless_doc_id']}: {message}")
71+
72+
except Exception as e:
73+
# Don't fail the expense update if Paperless update fails
74+
logger.error(f"Error updating Paperless for expense {expense_id}: {e}")
75+
76+
2677
@expenses_bp.route('/api/expenses', methods=['GET'])
2778
@login_required
2879
def get_expenses():
@@ -192,6 +243,9 @@ def update_expense(expense_id: int):
192243
# Fetch the updated expense to return full data for optimistic UI
193244
updated_expense = service.get_expense(expense_id, user_id)
194245

246+
# Update Paperless documents for this expense (non-blocking)
247+
_update_paperless_for_expense(user_id, expense_id, updated_expense)
248+
195249
# Build response
196250
response = {'success': True, 'expense': updated_expense}
197251

@@ -264,6 +318,9 @@ def patch_expense(expense_id: int):
264318
# Fetch updated expense
265319
updated_expense = service.get_expense(expense_id, user_id)
266320

321+
# Update Paperless documents for this expense (non-blocking)
322+
_update_paperless_for_expense(user_id, expense_id, updated_expense)
323+
267324
# Build response
268325
response = {'success': True, 'expense': updated_expense}
269326

app/routes/settings.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,40 @@ def retry_paperless_sync():
601601
'success': success,
602602
'message': message
603603
})
604+
605+
606+
@settings_bp.route('/settings/paperless-unsynced-count', methods=['GET'])
607+
@login_required
608+
def paperless_unsynced_count():
609+
"""Get count of unsynced attachments and documents"""
610+
from services.paperless_service import get_unsynced_count
611+
612+
user_id = session['user_id']
613+
counts = get_unsynced_count(user_id)
614+
615+
return jsonify({
616+
'success': True,
617+
'counts': counts
618+
})
619+
620+
621+
@settings_bp.route('/settings/paperless-bulk-sync', methods=['POST'])
622+
@login_required
623+
def paperless_bulk_sync():
624+
"""Sync all unsynced attachments and documents to Paperless"""
625+
from services.paperless_service import bulk_sync_unsynced
626+
627+
user_id = session['user_id']
628+
results = bulk_sync_unsynced(user_id)
629+
630+
if 'error' in results:
631+
return jsonify({
632+
'success': False,
633+
'message': results['error']
634+
}), 400
635+
636+
return jsonify({
637+
'success': True,
638+
'message': f"Synced {results['synced']} item(s). Failed: {results['failed']}. Skipped: {results['skipped']}.",
639+
'results': results
640+
})

0 commit comments

Comments
 (0)