Skip to content

Commit c501bb6

Browse files
Cap FSA/DCFSA reimbursements at election limit and fix HSA balance chart
FSA/DCFSA capacity capping: - Replace rejection with automatic capping when reimbursement exceeds remaining election capacity - Add get_fsa_remaining_capacity() and cap_amount_to_capacity() methods - Fix DCFSA capacity calculation for combined plans (HSA+DCFSA, FSA+DCFSA) - Update PATCH endpoint to use ExpenseService for proper validation - Return capping_info in API responses when amount is capped HSA balance chart fix: - Calculate historical balances backwards from current balance - Ensures upward trajectory when adding historical expenses with existing balance - Starting balance derived as: current_balance - net_effect_of_all_transactions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent a6c52f5 commit c501bb6

File tree

3 files changed

+267
-75
lines changed

3 files changed

+267
-75
lines changed

app/routes/expenses.py

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ExpenseValidationError,
1818
CategoryConflictError,
1919
CapacityExceededError,
20+
CapacityResult,
2021
)
2122

2223
expenses_bp = Blueprint('expenses', __name__)
@@ -101,7 +102,7 @@ def create_expense():
101102
}
102103

103104
# Create expense
104-
expense_id = service.create_expense(user_id, expense_data)
105+
expense_id, capacity_result = service.create_expense(user_id, expense_data)
105106

106107
# Handle file uploads
107108
if 'receipts' in request.files:
@@ -127,7 +128,21 @@ def create_expense():
127128

128129
# Fetch the created expense to return full data for optimistic UI
129130
created_expense = service.get_expense(expense_id, user_id)
130-
return jsonify({'success': True, 'expense_id': expense_id, 'expense': created_expense})
131+
132+
# Build response
133+
response = {'success': True, 'expense_id': expense_id, 'expense': created_expense}
134+
135+
# Add capping info if reimbursement was capped
136+
if capacity_result and capacity_result.was_capped:
137+
response['reimbursement_capped'] = True
138+
response['capping_info'] = {
139+
'original_amount': capacity_result.original_amount,
140+
'capped_amount': capacity_result.capped_amount,
141+
'remaining_capacity': capacity_result.remaining_capacity,
142+
'message': f'Reimbursement was capped at ${capacity_result.capped_amount:.2f} (remaining {expense_data["category"]} election capacity)'
143+
}
144+
145+
return jsonify(response)
131146

132147
except CategoryConflictError as e:
133148
conn.rollback()
@@ -155,19 +170,30 @@ def update_expense(expense_id: int):
155170

156171
try:
157172
service = ExpenseService(conn)
158-
service.update_expense(expense_id, user_id, data)
173+
_, capacity_result = service.update_expense(expense_id, user_id, data)
159174
conn.commit()
160175

161176
# Fetch the updated expense to return full data for optimistic UI
162177
updated_expense = service.get_expense(expense_id, user_id)
163-
return jsonify({'success': True, 'expense': updated_expense})
178+
179+
# Build response
180+
response = {'success': True, 'expense': updated_expense}
181+
182+
# Add capping info if reimbursement was capped
183+
if capacity_result and capacity_result.was_capped:
184+
response['reimbursement_capped'] = True
185+
response['capping_info'] = {
186+
'original_amount': capacity_result.original_amount,
187+
'capped_amount': capacity_result.capped_amount,
188+
'remaining_capacity': capacity_result.remaining_capacity,
189+
'message': f'Reimbursement was capped at ${capacity_result.capped_amount:.2f} (remaining election capacity)'
190+
}
191+
192+
return jsonify(response)
164193

165194
except CategoryConflictError as e:
166195
conn.rollback()
167196
return jsonify({'error': e.message}), 400
168-
except CapacityExceededError as e:
169-
conn.rollback()
170-
return jsonify({'error': e.message}), 400
171197
except ExpenseValidationError as e:
172198
conn.rollback()
173199
if 'not found' in e.message.lower():
@@ -183,17 +209,19 @@ def update_expense(expense_id: int):
183209
@expenses_bp.route('/api/expenses/<int:expense_id>', methods=['PATCH'])
184210
@login_required
185211
def patch_expense(expense_id: int):
186-
"""API endpoint to partially update an expense (status, provider, amount_inflow)."""
212+
"""API endpoint to partially update an expense (status, provider, amount_inflow).
213+
214+
Uses the ExpenseService to ensure FSA/DCFSA capacity validation and HSA balance tracking.
215+
"""
187216
data = request.json
188217
user_id = session['user_id']
189218
conn = get_db_connection()
190219

191220
try:
221+
service = ExpenseService(conn)
222+
192223
# Get current expense for smart reimbursement logic
193-
expense = conn.execute(
194-
'SELECT id, amount_outflow FROM expenses WHERE id = ? AND user_id = ?',
195-
(expense_id, user_id)
196-
).fetchone()
224+
expense = service.get_expense(expense_id, user_id)
197225

198226
if not expense:
199227
return jsonify({'error': 'Expense not found'}), 404
@@ -213,19 +241,36 @@ def patch_expense(expense_id: int):
213241
if not filtered_data:
214242
return jsonify({'error': 'No valid fields to update'}), 400
215243

216-
# Build update query
217-
fields = [f"{field} = ?" for field in filtered_data.keys()]
218-
values = list(filtered_data.values())
219-
fields.append("updated_at = ?")
220-
values.append(datetime.now().isoformat())
221-
values.extend([expense_id, user_id])
222-
223-
query = f"UPDATE expenses SET {', '.join(fields)} WHERE id = ? AND user_id = ?"
224-
conn.execute(query, values)
244+
# Use the service to update (handles FSA/DCFSA capacity capping and HSA balance)
245+
_, capacity_result = service.update_expense(expense_id, user_id, filtered_data)
225246
conn.commit()
226247

227-
return jsonify({'success': True})
248+
# Fetch updated expense
249+
updated_expense = service.get_expense(expense_id, user_id)
250+
251+
# Build response
252+
response = {'success': True, 'expense': updated_expense}
228253

254+
# Add capping info if reimbursement was capped
255+
if capacity_result and capacity_result.was_capped:
256+
response['reimbursement_capped'] = True
257+
response['capping_info'] = {
258+
'original_amount': capacity_result.original_amount,
259+
'capped_amount': capacity_result.capped_amount,
260+
'remaining_capacity': capacity_result.remaining_capacity,
261+
'message': f'Reimbursement was capped at ${capacity_result.capped_amount:.2f} (remaining {expense["category"]} election capacity)'
262+
}
263+
264+
return jsonify(response)
265+
266+
except CategoryConflictError as e:
267+
conn.rollback()
268+
return jsonify({'error': e.message}), 400
269+
except ExpenseValidationError as e:
270+
conn.rollback()
271+
if 'not found' in e.message.lower():
272+
return jsonify({'error': e.message}), 404
273+
return jsonify({'error': e.message}), 400
229274
except Exception as e:
230275
conn.rollback()
231276
return jsonify({'error': str(e)}), 500

0 commit comments

Comments
 (0)