Skip to content
316 changes: 228 additions & 88 deletions backend/app/auth/service.py

Large diffs are not rendered by default.

158 changes: 115 additions & 43 deletions backend/app/expenses/service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
from collections import defaultdict, deque
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional

from app.config import logger
from app.database import mongodb
Expand All @@ -15,7 +14,8 @@
SettlementStatus,
SplitType,
)
from bson import ObjectId
from bson import ObjectId, errors
from fastapi import HTTPException


class ExpenseService:
Expand Down Expand Up @@ -46,15 +46,22 @@ async def create_expense(
# Validate and convert group_id to ObjectId
try:
group_obj_id = ObjectId(group_id)
except Exception:
raise ValueError("Group not found or user not a member")
except errors.InvalidId: # Incorrect ObjectId format
logger.warning(f"Invalid group ID format: {group_id}")
raise HTTPException(status_code=400, detail="Invalid group ID")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Use proper exception chaining in except blocks.

When raising exceptions within except blocks, use from e to preserve the exception chain or from None to explicitly suppress it. This helps with debugging by preserving the original exception context.

For example, change:

-        except errors.InvalidId:
+        except errors.InvalidId as e:
             logger.warning(f"Invalid group ID format: {group_id}")
-            raise HTTPException(status_code=400, detail="Invalid group ID")
+            raise HTTPException(status_code=400, detail="Invalid group ID") from e

Apply similar changes to all other except blocks that raise exceptions.

Also applies to: 53-54, 244-246, 249-250, 296-297, 433-433

🧰 Tools
πŸͺ› Ruff (0.12.2)

50-50: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

πŸ€– Prompt for AI Agents
In backend/app/expenses/service.py at lines 50, 53-54, 244-246, 249-250,
296-297, and 433, update all raise statements inside except blocks to use proper
exception chaining by adding 'from e' after the raise statement to preserve the
original exception context or 'from None' if you want to suppress it. This
involves modifying lines like 'raise HTTPException(...)' to 'raise
HTTPException(...) from e' where 'e' is the caught exception variable.

except Exception as e:
logger.error(f"Unexpected error parsing groupId: {e}")
raise HTTPException(
status_code=500, detail="Failed to process group ID")

# Verify user is member of the group
group = await self.groups_collection.find_one(
{"_id": group_obj_id, "members.userId": user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
if not group: # User not a member of the group
raise HTTPException(
status_code=403, detail="You are not a member of this group"
)

# Create expense document
expense_doc = {
Expand Down Expand Up @@ -231,21 +238,32 @@ async def get_expense_by_id(
try:
group_obj_id = ObjectId(group_id)
expense_obj_id = ObjectId(expense_id)
except Exception:
raise ValueError("Group not found or user not a member")
except errors.InvalidId: # Incorrect ObjectId format for group_id or expense_id
logger.warning(
f"Invalid ObjectId(s): group_id={group_id}, expense_id={expense_id}"
)
raise HTTPException(
status_code=400, detail="Invalid group ID or expense ID"
)
except Exception as e:
logger.error(f"Unexpected error parsing IDs: {e}")
raise HTTPException(
status_code=500, detail="Unable to process IDs")

# Verify user access
group = await self.groups_collection.find_one(
{"_id": group_obj_id, "members.userId": user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
if not group: # Unauthorized access
raise HTTPException(
status_code=403, detail="You are not a member of this group"
)

expense_doc = await self.expenses_collection.find_one(
{"_id": expense_obj_id, "groupId": group_id}
)
if not expense_doc:
raise ValueError("Expense not found")
if not expense_doc: # Expense not found
raise HTTPException(status_code=404, detail="Expense not found")

expense = await self._expense_doc_to_response(expense_doc)

Expand Down Expand Up @@ -274,30 +292,39 @@ async def update_expense(
# Validate ObjectId format
try:
expense_obj_id = ObjectId(expense_id)
except Exception as e:
raise ValueError(f"Invalid expense ID format: {expense_id}")
except errors.InvalidId:
logger.warning(f"Invalid expense ID format: {expense_id}")
raise HTTPException(
status_code=400, detail="Invalid expense ID format")

# Verify user access and that they created the expense
expense_doc = await self.expenses_collection.find_one(
{"_id": expense_obj_id, "groupId": group_id, "createdBy": user_id}
)
if not expense_doc:
raise ValueError("Expense not found or not authorized to edit")
if not expense_doc: # Expense not found or user not authorized
raise HTTPException(
status_code=403,
detail="Not authorized to update this expense or it does not exist",
)

# Validate splits against current or new amount if both are being updated
if updates.splits is not None and updates.amount is not None:
total_split = sum(split.amount for split in updates.splits)
if abs(total_split - updates.amount) > 0.01:
raise ValueError(
"Split amounts must sum to total expense amount")
raise HTTPException(
status_code=400,
detail="Split amounts must sum to total expense amount",
)

# If only splits are being updated, validate against current amount
elif updates.splits is not None:
current_amount = expense_doc["amount"]
total_split = sum(split.amount for split in updates.splits)
if abs(total_split - current_amount) > 0.01:
raise ValueError(
"Split amounts must sum to current expense amount")
raise HTTPException(
status_code=400,
detail="Split amounts must sum to total expense amount",
)

# Store original data for history
original_data = {
Expand Down Expand Up @@ -332,7 +359,8 @@ async def update_expense(
user.get(
"name", "Unknown User") if user else "Unknown User"
)
except:
except Exception as e:
logger.warning(f"Failed to fetch user for history: {e}")
user_name = "Unknown User"

history_entry = {
Expand All @@ -349,16 +377,20 @@ async def update_expense(
{"$set": update_doc, "$push": {"history": history_entry}},
)

if result.matched_count == 0:
raise ValueError("Expense not found during update")
if result.matched_count == 0: # Expense not found during update
raise HTTPException(
status_code=404, detail="Expense not found during update"
)
else:
# No actual changes, just update the timestamp
result = await self.expenses_collection.update_one(
{"_id": expense_obj_id}, {"$set": update_doc}
)

if result.matched_count == 0:
raise ValueError("Expense not found during update")
raise HTTPException(
status_code=404, detail="Expense not found during update"
)

# If splits changed, recalculate settlements
if updates.splits is not None or updates.amount is not None:
Expand All @@ -378,10 +410,9 @@ async def update_expense(
await self._create_settlements_for_expense(
updated_expense, user_id
)
except Exception as e:
except Exception:
logger.error(
f"Warning: Failed to recalculate settlements: {e}",
exc_info=True,
f"Warning: Failed to recalculate settlements", exc_info=True
)
Comment on lines +413 to 416
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix f-string without placeholders.

The f-string on line 415 has no placeholders.

-                except Exception:
+                except Exception:
                     logger.error(
-                        f"Warning: Failed to recalculate settlements", exc_info=True
+                        "Warning: Failed to recalculate settlements", exc_info=True
                     )
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception:
logger.error(
f"Warning: Failed to recalculate settlements: {e}",
exc_info=True,
f"Warning: Failed to recalculate settlements", exc_info=True
)
except Exception:
logger.error(
"Warning: Failed to recalculate settlements", exc_info=True
)
🧰 Tools
πŸͺ› Ruff (0.12.2)

415-415: f-string without any placeholders

Remove extraneous f prefix

(F541)

πŸ€– Prompt for AI Agents
In backend/app/expenses/service.py around lines 413 to 416, the f-string used in
the logger.error call does not contain any placeholders, making the f-string
unnecessary. Replace the f-string with a regular string literal by removing the
leading 'f' before the string to fix this.

# Continue anyway, as the expense update succeeded

Expand All @@ -390,14 +421,26 @@ async def update_expense(
{"_id": expense_obj_id}
)
if not updated_expense:
raise ValueError("Failed to retrieve updated expense")
raise HTTPException(
status_code=500, detail="Failed to retrieve updated expense"
)

return await self._expense_doc_to_response(updated_expense)

except ValueError:
# Allowing FastAPI exception to bubble up for proper handling
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in update_expense: {str(e)}", exc_info=True)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except (
Exception
) as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback
logger.exception(
f"Unhandled error in update_expense for expense {expense_id}: {e}"
)
import traceback

traceback.print_exc()
raise Exception(f"Database error during expense update: {str(e)}")
Comment on lines +437 to 444
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove redundant traceback printing and add exception chaining.

The logger.exception() already logs the full traceback, making traceback.print_exc() redundant.

         except (
             Exception
         ) as e:  # logger.exception() will provide the entire traceback, so its safe to remove traceback
             logger.exception(
                 f"Unhandled error in update_expense for expense {expense_id}: {e}"
             )
-            import traceback
-
-            traceback.print_exc()
-            raise Exception(f"Database error during expense update: {str(e)}")
+            raise Exception(f"Database error during expense update: {str(e)}") from e
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback
logger.exception(
f"Unhandled error in update_expense for expense {expense_id}: {e}"
)
import traceback
traceback.print_exc()
raise Exception(f"Database error during expense update: {str(e)}")
except (
Exception
) as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback
logger.exception(
f"Unhandled error in update_expense for expense {expense_id}: {e}"
)
raise Exception(f"Database error during expense update: {str(e)}") from e
🧰 Tools
πŸͺ› Ruff (0.12.2)

444-444: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

πŸ€– Prompt for AI Agents
In backend/app/expenses/service.py around lines 437 to 444, remove the redundant
call to traceback.print_exc() since logger.exception() already logs the full
traceback. Additionally, when re-raising the exception, use exception chaining
by adding "from e" to preserve the original exception context.


async def delete_expense(
Expand All @@ -411,7 +454,13 @@ async def delete_expense(
"createdBy": user_id}
)
if not expense_doc:
raise ValueError("Expense not found or not authorized to delete")
logger.warning(
f"Unauthorized delete attempt or missing expense: {expense_id} by user {user_id}"
)
raise HTTPException(
status_code=403,
detail="Not authorized to delete this expense or it does not exist",
)

# Delete settlements for this expense
await self.settlements_collection.delete_many({"expenseId": expense_id})
Expand Down Expand Up @@ -576,7 +625,12 @@ async def create_manual_settlement(
{"_id": ObjectId(group_id), "members.userId": user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
logger.warning(
f"Unauthorized access attempt to group {group_id} by user {user_id}"
)
raise HTTPException(
status_code=403, detail="Group not found or user not a member"
)

# Get user names
users = await self.users_collection.find(
Expand Down Expand Up @@ -666,7 +720,12 @@ async def get_group_settlements(
{"_id": ObjectId(group_id), "members.userId": user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
logger.warning(
f"Unauthorized access attempt to group {group_id} by user {user_id}"
)
raise HTTPException(
status_code=403, detail="Group not found or user not a member"
)

# Build query
query = {"groupId": group_id}
Expand Down Expand Up @@ -705,17 +764,24 @@ async def get_settlement_by_id(

# Verify user access
group = await self.groups_collection.find_one(
{"_id": ObjectId(group_id), "members.userId": user_id}
{
"_id": ObjectId(
group_id
), # Assuming valid object ID format (same as above functions)
"members.userId": user_id,
}
)
if not group:
raise ValueError("Group not found or user not a member")
raise HTTPException(
status_code=403, detail="Group not found or user not a member"
)

settlement_doc = await self.settlements_collection.find_one(
{"_id": ObjectId(settlement_id), "groupId": group_id}
)

if not settlement_doc:
raise ValueError("Settlement not found")
raise HTTPException(status_code=404, detail="Settlement not found")

return Settlement(**{**settlement_doc, "_id": str(settlement_doc["_id"])})

Expand All @@ -740,7 +806,7 @@ async def update_settlement_status(
)

if result.matched_count == 0:
raise ValueError("Settlement not found")
raise HTTPException(status_code=404, detail="Settlement not found")

# Get updated settlement
settlement_doc = await self.settlements_collection.find_one(
Expand All @@ -759,7 +825,9 @@ async def delete_settlement(
{"_id": ObjectId(group_id), "members.userId": user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
raise HTTPException(
status_code=403, detail="Group not found or user not a member"
)

result = await self.settlements_collection.delete_one(
{"_id": ObjectId(settlement_id), "groupId": group_id}
Expand All @@ -777,7 +845,9 @@ async def get_user_balance_in_group(
{"_id": ObjectId(group_id), "members.userId": current_user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
raise HTTPException(
status_code=403, detail="Group not found or user not a member"
)

# Get user info
user = await self.users_collection.find_one({"_id": ObjectId(target_user_id)})
Expand Down Expand Up @@ -1109,7 +1179,9 @@ async def get_group_analytics(
{"_id": ObjectId(group_id), "members.userId": user_id}
)
if not group:
raise ValueError("Group not found or user not a member")
raise HTTPException(
status_code=403, detail="Group not found or user not a member"
)

# Build date range
if period == "month" and year and month:
Expand Down
Loading
Loading