|
1 | 1 | from collections import defaultdict |
2 | | -from datetime import datetime, timedelta |
| 2 | +from datetime import datetime, timedelta, timezone |
3 | 3 | from typing import Any, Dict, List, Optional |
4 | 4 |
|
5 | 5 | from app.config import logger |
@@ -953,141 +953,203 @@ async def get_user_balance_in_group( |
953 | 953 | } |
954 | 954 |
|
955 | 955 | async def get_friends_balance_summary(self, user_id: str) -> Dict[str, Any]: |
956 | | - """Get cross-group friend balances for a user""" |
| 956 | + """ |
| 957 | + Get cross-group friend balances using optimized aggregation pipeline. |
957 | 958 |
|
958 | | - # Get all groups user belongs to |
| 959 | + Performance: Optimized to use single aggregation query instead of N×M queries. |
| 960 | + Example: 20 friends × 5 groups = 3 queries total (vs 100+ with naive approach). |
| 961 | +
|
| 962 | + Uses MongoDB aggregation to calculate all balances at once, then batch enriches |
| 963 | + with user and group details for optimal performance. |
| 964 | + """ |
| 965 | + |
| 966 | + # First, get all groups user belongs to (need this to filter friends properly) |
959 | 967 | groups = await self.groups_collection.find({"members.userId": user_id}).to_list( |
960 | | - None |
| 968 | + length=500 |
961 | 969 | ) |
962 | 970 |
|
963 | | - friends_balance = [] |
964 | | - user_totals = {"totalOwedToYou": 0, "totalYouOwe": 0} |
| 971 | + if not groups: |
| 972 | + return { |
| 973 | + "friendsBalance": [], |
| 974 | + "summary": { |
| 975 | + "totalOwedToYou": 0, |
| 976 | + "totalYouOwe": 0, |
| 977 | + "netBalance": 0, |
| 978 | + "friendCount": 0, |
| 979 | + "activeGroups": 0, |
| 980 | + }, |
| 981 | + } |
965 | 982 |
|
966 | | - # Get all unique friends across groups |
967 | | - friend_ids = set() |
| 983 | + # Extract group IDs and friend IDs (only from user's groups) |
| 984 | + group_ids = [str(g["_id"]) for g in groups] |
| 985 | + friend_ids_in_groups = set() |
968 | 986 | for group in groups: |
969 | 987 | for member in group["members"]: |
970 | 988 | if member["userId"] != user_id: |
971 | | - friend_ids.add(member["userId"]) |
| 989 | + friend_ids_in_groups.add(member["userId"]) |
972 | 990 |
|
973 | | - # Get user names & images |
974 | | - users = await self.users_collection.find( |
975 | | - {"_id": {"$in": [ObjectId(uid) for uid in friend_ids]}} |
976 | | - ).to_list(None) |
977 | | - user_names = {str(user["_id"]): user.get("name", "Unknown") for user in users} |
978 | | - user_images = {str(user["_id"]): user.get("imageUrl") for user in users} |
| 991 | + # OPTIMIZATION: Single aggregation to calculate all friend balances at once |
| 992 | + # Only for friends in user's groups and groups user belongs to |
| 993 | + pipeline = [ |
| 994 | + # Step 1: Match settlements in user's groups involving the user |
| 995 | + { |
| 996 | + "$match": { |
| 997 | + "groupId": {"$in": group_ids}, |
| 998 | + "$or": [ |
| 999 | + { |
| 1000 | + "payerId": user_id, |
| 1001 | + "payeeId": {"$in": list(friend_ids_in_groups)}, |
| 1002 | + }, |
| 1003 | + { |
| 1004 | + "payeeId": user_id, |
| 1005 | + "payerId": {"$in": list(friend_ids_in_groups)}, |
| 1006 | + }, |
| 1007 | + ], |
| 1008 | + } |
| 1009 | + }, |
| 1010 | + # Step 2: Calculate net balance per friend per group |
| 1011 | + { |
| 1012 | + "$group": { |
| 1013 | + "_id": { |
| 1014 | + "friendId": { |
| 1015 | + "$cond": [ |
| 1016 | + {"$eq": ["$payerId", user_id]}, |
| 1017 | + "$payeeId", |
| 1018 | + "$payerId", |
| 1019 | + ] |
| 1020 | + }, |
| 1021 | + "groupId": "$groupId", |
| 1022 | + }, |
| 1023 | + "balance": { |
| 1024 | + "$sum": { |
| 1025 | + "$cond": [ |
| 1026 | + # If user is payer, friend owes user (positive) |
| 1027 | + {"$eq": ["$payerId", user_id]}, |
| 1028 | + "$amount", |
| 1029 | + # If user is payee, user owes friend (negative) |
| 1030 | + {"$multiply": ["$amount", -1]}, |
| 1031 | + ] |
| 1032 | + } |
| 1033 | + }, |
| 1034 | + } |
| 1035 | + }, |
| 1036 | + # Step 3: Group by friend to get total balance across all groups |
| 1037 | + { |
| 1038 | + "$group": { |
| 1039 | + "_id": "$_id.friendId", |
| 1040 | + "totalBalance": {"$sum": "$balance"}, |
| 1041 | + "groups": { |
| 1042 | + "$push": {"groupId": "$_id.groupId", "balance": "$balance"} |
| 1043 | + }, |
| 1044 | + } |
| 1045 | + }, |
| 1046 | + # Step 4: Filter out friends with zero balance |
| 1047 | + {"$match": {"$expr": {"$gt": [{"$abs": "$totalBalance"}, 0.01]}}}, |
| 1048 | + ] |
979 | 1049 |
|
980 | | - for friend_id in friend_ids: |
981 | | - friend_balance_data = { |
982 | | - "userId": friend_id, |
983 | | - "userName": user_names.get(friend_id, "Unknown"), |
984 | | - # Populate image directly from users collection to avoid extra client round-trips |
985 | | - "userImageUrl": user_images.get(friend_id), |
986 | | - "netBalance": 0, |
987 | | - "owesYou": False, |
988 | | - "breakdown": [], |
989 | | - "lastActivity": datetime.utcnow(), |
| 1050 | + # Execute aggregation - Single query for all friend balances |
| 1051 | + try: |
| 1052 | + results = await self.settlements_collection.aggregate(pipeline).to_list( |
| 1053 | + length=500 |
| 1054 | + ) |
| 1055 | + except Exception as e: |
| 1056 | + logger.error(f"Error in optimized friends balance aggregation: {e}") |
| 1057 | + results = [] |
| 1058 | + |
| 1059 | + if not results: |
| 1060 | + # No balances found |
| 1061 | + return { |
| 1062 | + "friendsBalance": [], |
| 1063 | + "summary": { |
| 1064 | + "totalOwedToYou": 0, |
| 1065 | + "totalYouOwe": 0, |
| 1066 | + "netBalance": 0, |
| 1067 | + "friendCount": 0, |
| 1068 | + "activeGroups": len(groups), |
| 1069 | + }, |
990 | 1070 | } |
991 | 1071 |
|
992 | | - total_friend_balance = 0 |
| 1072 | + # Extract unique friend IDs for batch fetching |
| 1073 | + friend_ids = list({result["_id"] for result in results}) |
993 | 1074 |
|
994 | | - # Calculate balance for each group |
995 | | - for group in groups: |
996 | | - group_id = str(group["_id"]) |
| 1075 | + # Build group map from groups we already fetched |
| 1076 | + groups_map = {str(g["_id"]): g.get("name", "Unknown Group") for g in groups} |
997 | 1077 |
|
998 | | - # Check if friend is in this group |
999 | | - friend_in_group = any( |
1000 | | - member["userId"] == friend_id for member in group["members"] |
1001 | | - ) |
1002 | | - if not friend_in_group: |
1003 | | - continue |
| 1078 | + # OPTIMIZATION: Batch fetch all friend details in one query |
| 1079 | + try: |
| 1080 | + friends_cursor = self.users_collection.find( |
| 1081 | + {"_id": {"$in": [ObjectId(fid) for fid in friend_ids]}}, |
| 1082 | + {"_id": 1, "name": 1, "imageUrl": 1}, |
| 1083 | + ) |
| 1084 | + friends_list = await friends_cursor.to_list(length=500) |
| 1085 | + friends_map = {str(f["_id"]): f for f in friends_list} |
| 1086 | + except Exception as e: |
| 1087 | + logger.error(f"Error batch fetching friend details: {e}") |
| 1088 | + friends_map = {} |
1004 | 1089 |
|
1005 | | - # Calculate net balance between user and friend in this group |
1006 | | - pipeline = [ |
1007 | | - { |
1008 | | - "$match": { |
1009 | | - "groupId": group_id, |
1010 | | - "$or": [ |
1011 | | - {"payerId": user_id, "payeeId": friend_id}, |
1012 | | - {"payerId": friend_id, "payeeId": user_id}, |
1013 | | - ], |
1014 | | - } |
1015 | | - }, |
1016 | | - { |
1017 | | - "$group": { |
1018 | | - "_id": None, |
1019 | | - "userOwes": { |
1020 | | - "$sum": { |
1021 | | - "$cond": [ |
1022 | | - { |
1023 | | - "$and": [ |
1024 | | - {"$eq": ["$payerId", friend_id]}, |
1025 | | - {"$eq": ["$payeeId", user_id]}, |
1026 | | - ] |
1027 | | - }, |
1028 | | - "$amount", |
1029 | | - 0, |
1030 | | - ] |
1031 | | - } |
1032 | | - }, |
1033 | | - "friendOwes": { |
1034 | | - "$sum": { |
1035 | | - "$cond": [ |
1036 | | - { |
1037 | | - "$and": [ |
1038 | | - {"$eq": ["$payerId", user_id]}, |
1039 | | - {"$eq": ["$payeeId", friend_id]}, |
1040 | | - ] |
1041 | | - }, |
1042 | | - "$amount", |
1043 | | - 0, |
1044 | | - ] |
1045 | | - } |
1046 | | - }, |
1047 | | - } |
1048 | | - }, |
1049 | | - ] |
| 1090 | + # Post-process results to build final response |
| 1091 | + friends_balance = [] |
| 1092 | + user_totals = {"totalOwedToYou": 0, "totalYouOwe": 0} |
1050 | 1093 |
|
1051 | | - result = await self.settlements_collection.aggregate(pipeline).to_list( |
1052 | | - None |
1053 | | - ) |
1054 | | - balance_data = result[0] if result else {"userOwes": 0, "friendOwes": 0} |
| 1094 | + for result in results: |
| 1095 | + friend_id = result["_id"] |
| 1096 | + total_balance = result.get("totalBalance", 0) |
1055 | 1097 |
|
1056 | | - group_balance = balance_data["friendOwes"] - balance_data["userOwes"] |
1057 | | - total_friend_balance += group_balance |
| 1098 | + # Get friend details from map |
| 1099 | + friend_details = friends_map.get(friend_id) |
1058 | 1100 |
|
1059 | | - if ( |
1060 | | - abs(group_balance) > 0.01 |
1061 | | - ): # Only include if there's a significant balance |
1062 | | - friend_balance_data["breakdown"].append( |
| 1101 | + # Build breakdown by group |
| 1102 | + breakdown = [] |
| 1103 | + for group_item in result.get("groups", []): |
| 1104 | + group_id = group_item["groupId"] |
| 1105 | + group_balance = group_item["balance"] |
| 1106 | + |
| 1107 | + # Only include groups with significant balance |
| 1108 | + if abs(group_balance) > 0.01: |
| 1109 | + breakdown.append( |
1063 | 1110 | { |
1064 | 1111 | "groupId": group_id, |
1065 | | - "groupName": group["name"], |
1066 | | - "balance": group_balance, |
| 1112 | + "groupName": groups_map.get(group_id, "Unknown Group"), |
| 1113 | + "balance": round(group_balance, 2), |
1067 | 1114 | "owesYou": group_balance > 0, |
1068 | 1115 | } |
1069 | 1116 | ) |
1070 | 1117 |
|
1071 | | - if ( |
1072 | | - abs(total_friend_balance) > 0.01 |
1073 | | - ): # Only include friends with non-zero balance |
1074 | | - friend_balance_data["netBalance"] = total_friend_balance |
1075 | | - friend_balance_data["owesYou"] = total_friend_balance > 0 |
| 1118 | + # Build friend balance object |
| 1119 | + friend_data = { |
| 1120 | + "userId": friend_id, |
| 1121 | + "userName": ( |
| 1122 | + friend_details.get("name", "Unknown") |
| 1123 | + if friend_details |
| 1124 | + else "Unknown" |
| 1125 | + ), |
| 1126 | + "userImageUrl": ( |
| 1127 | + friend_details.get("imageUrl") if friend_details else None |
| 1128 | + ), |
| 1129 | + "netBalance": round(total_balance, 2), |
| 1130 | + "owesYou": total_balance > 0, |
| 1131 | + "breakdown": breakdown, |
| 1132 | + "lastActivity": datetime.now( |
| 1133 | + timezone.utc |
| 1134 | + ), # TODO: Calculate actual last activity |
| 1135 | + } |
1076 | 1136 |
|
1077 | | - if total_friend_balance > 0: |
1078 | | - user_totals["totalOwedToYou"] += total_friend_balance |
1079 | | - else: |
1080 | | - user_totals["totalYouOwe"] += abs(total_friend_balance) |
| 1137 | + friends_balance.append(friend_data) |
1081 | 1138 |
|
1082 | | - friends_balance.append(friend_balance_data) |
| 1139 | + # Update totals |
| 1140 | + if total_balance > 0: |
| 1141 | + user_totals["totalOwedToYou"] += total_balance |
| 1142 | + else: |
| 1143 | + user_totals["totalYouOwe"] += abs(total_balance) |
1083 | 1144 |
|
1084 | 1145 | return { |
1085 | 1146 | "friendsBalance": friends_balance, |
1086 | 1147 | "summary": { |
1087 | | - "totalOwedToYou": user_totals["totalOwedToYou"], |
1088 | | - "totalYouOwe": user_totals["totalYouOwe"], |
1089 | | - "netBalance": user_totals["totalOwedToYou"] |
1090 | | - - user_totals["totalYouOwe"], |
| 1148 | + "totalOwedToYou": round(user_totals["totalOwedToYou"], 2), |
| 1149 | + "totalYouOwe": round(user_totals["totalYouOwe"], 2), |
| 1150 | + "netBalance": round( |
| 1151 | + user_totals["totalOwedToYou"] - user_totals["totalYouOwe"], 2 |
| 1152 | + ), |
1091 | 1153 | "friendCount": len(friends_balance), |
1092 | 1154 | "activeGroups": len(groups), |
1093 | 1155 | }, |
|
0 commit comments