profileShare

rasmusjy / splitapp-backend-microservices

Read-only snapshot

No repository description.

main default branch 501 files Expires Sep 13, 2026, 9:06 AM
SettlementService.cs 12,899 bytes
1 using SplitApp.WebApp.Application.DTO;
2 using SplitApp.WebApp.Application.Helpers;
3 using SplitApp.WebApp.Application.Mappers;
4 using SplitApp.Modules.Trips.Domain.Entities;
5 using SplitApp.Modules.Trips.Domain.Enums;
6 using SplitApp.Modules.Expenses.Domain.Entities;
7 using SplitApp.Modules.Expenses.Domain.Enums;
8 using SplitApp.WebApp.Application.Contracts;
9
10 namespace SplitApp.WebApp.Application.Services;
11
12 public class SettlementService : ISettlementService
13 {
14 private readonly IAppUnitOfWork _uow;
15
16 public SettlementService(IAppUnitOfWork uow)
17 {
18 _uow = uow;
19 }
20
21 public async Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId)
22 {
23 var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId);
24 if (trip == null) return new List<BalanceEntry>();
25
26 var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR";
27
28 var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList();
29
30 var balances = new Dictionary<Guid, BalanceEntry>();
31 foreach (var p in participants)
32 {
33 var displayName = p.User?.DisplayName ?? "";
34 balances[p.UserId] = new BalanceEntry
35 {
36 UserId = p.UserId,
37 UserName = !string.IsNullOrEmpty(displayName)
38 ? displayName
39 : (p.User?.Email ?? "Unknown"),
40 TotalPaid = 0,
41 TotalOwed = 0
42 };
43 }
44
45 var expenses = (await _uow.Expenses.GetByTripIdAsync(tripId)).ToList();
46
47 // Need expenses with splits - fetch each with details
48 foreach (var expense in expenses)
49 {
50 var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(expense.Id);
51 if (expenseWithSplits == null) continue;
52
53 var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode;
54 var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode);
55
56 if (balances.ContainsKey(expenseWithSplits.PaidByUserId))
57 {
58 balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount;
59 }
60
61 if (expenseWithSplits.Splits != null)
62 {
63 foreach (var split in expenseWithSplits.Splits)
64 {
65 var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode);
66 if (balances.ContainsKey(split.UserId))
67 {
68 balances[split.UserId].TotalOwed += convertedSplit;
69 }
70 }
71 }
72 }
73
74 return balances.Values.OrderByDescending(b => b.NetBalance).ToList();
75 }
76
77 public async Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId)
78 {
79 var balanceList = await CalculateBalancesAsync(tripId);
80
81 var creditors = balanceList.Where(b => b.NetBalance > 0.01m)
82 .Select(b => new { b.UserId, Amount = b.NetBalance })
83 .OrderByDescending(c => c.Amount)
84 .ToList();
85
86 var debtors = balanceList.Where(b => b.NetBalance < -0.01m)
87 .Select(b => new { b.UserId, Amount = -b.NetBalance })
88 .OrderByDescending(d => d.Amount)
89 .ToList();
90
91 if (!creditors.Any() || !debtors.Any()) return null;
92
93 var plan = new SettlementPlan
94 {
95 Id = Guid.NewGuid(),
96 TripId = tripId,
97 CreatedByUserId = userId,
98 TotalAmount = creditors.Sum(c => c.Amount),
99 Status = ESettlementStatus.Pending
100 };
101
102 _uow.SettlementPlans.Add(plan);
103
104 var paymentRepo = _uow.GetRepository<SettlementPayment>();
105
106 var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount);
107 var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount);
108 var sortedCreditors = creditBalances.Keys.ToList();
109 var sortedDebtors = debtBalances.Keys.ToList();
110 var ci = 0;
111 var di = 0;
112
113 while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
114 {
115 var creditorId = sortedCreditors[ci];
116 var debtorId = sortedDebtors[di];
117 var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]);
118
119 if (amount > 0.01m)
120 {
121 paymentRepo.Add(new SettlementPayment
122 {
123 Id = Guid.NewGuid(),
124 SettlementPlanId = plan.Id,
125 FromUserId = debtorId,
126 ToUserId = creditorId,
127 Amount = Math.Round(amount, 2),
128 Status = EPaymentStatus.Pending
129 });
130 }
131
132 creditBalances[creditorId] -= amount;
133 debtBalances[debtorId] -= amount;
134 if (creditBalances[creditorId] < 0.01m) ci++;
135 if (debtBalances[debtorId] < 0.01m) di++;
136 }
137
138 await _uow.SaveChangesAsync();
139
140 // Return the plan with navigation properties loaded
141 var reloaded = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
142 return reloaded == null ? null : SettlementBllDtoFactory.Create(reloaded, includePayments: true);
143 }
144
145 public List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balanceList)
146 {
147 var result = new List<PreviewPayment>();
148
149 var creditors = balanceList.Where(b => b.NetBalance > 0.01m)
150 .Select(b => new { b.UserName, Amount = b.NetBalance })
151 .OrderByDescending(c => c.Amount).ToList();
152
153 var debtors = balanceList.Where(b => b.NetBalance < -0.01m)
154 .Select(b => new { b.UserName, Amount = -b.NetBalance })
155 .OrderByDescending(d => d.Amount).ToList();
156
157 if (!creditors.Any() || !debtors.Any()) return result;
158
159 var creditBalances = creditors.ToDictionary(c => c.UserName, c => c.Amount);
160 var debtBalances = debtors.ToDictionary(d => d.UserName, d => d.Amount);
161 var sortedCreditors = creditBalances.Keys.ToList();
162 var sortedDebtors = debtBalances.Keys.ToList();
163 var ci = 0;
164 var di = 0;
165
166 while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
167 {
168 var creditor = sortedCreditors[ci];
169 var debtor = sortedDebtors[di];
170 var amount = Math.Min(creditBalances[creditor], debtBalances[debtor]);
171
172 if (amount > 0.01m)
173 {
174 result.Add(new PreviewPayment
175 {
176 FromUserName = debtor,
177 ToUserName = creditor,
178 Amount = Math.Round(amount, 2)
179 });
180 }
181
182 creditBalances[creditor] -= amount;
183 debtBalances[debtor] -= amount;
184 if (creditBalances[creditor] < 0.01m) ci++;
185 if (debtBalances[debtor] < 0.01m) di++;
186 }
187
188 return result;
189 }
190
191 public async Task MarkPaidAsync(Guid paymentId, Guid userId)
192 {
193 var paymentRepo = _uow.GetRepository<SettlementPayment>();
194 var payment = await paymentRepo.GetByIdAsync(paymentId);
195 if (payment == null) return;
196
197 if (payment.FromUserId != userId) return;
198
199 payment.Status = EPaymentStatus.MarkedPaid;
200 payment.MarkedPaidAt = DateTime.UtcNow;
201
202 paymentRepo.Update(payment);
203 await _uow.SaveChangesAsync();
204 }
205
206 // --- New IDOR-protected helpers ---
207
208 public async Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId)
209 {
210 if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
211 return new List<BalanceEntry>();
212 return await CalculateBalancesAsync(tripId);
213 }
214
215 public async Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId)
216 {
217 if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
218 return null;
219 var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
220 return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true);
221 }
222
223 public async Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId)
224 {
225 var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
226 return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true);
227 }
228
229 public async Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId)
230 {
231 var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId);
232 return payment == null ? null : SettlementPaymentBllDtoFactory.Create(payment);
233 }
234
235 public async Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId)
236 {
237 var plan = await _uow.SettlementPlans.GetByIdAsync(planId);
238 return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true);
239 }
240
241 public async Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId)
242 {
243 var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId);
244 if (payment == null) return (false, "notfound");
245
246 var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
247 if (plan == null) return (false, "notfound");
248
249 if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId))
250 return (false, "forbidden");
251
252 if (payment.FromUserId != userId) return (false, "forbidden");
253
254 await MarkPaidAsync(paymentId, userId);
255 return (true, null);
256 }
257
258 public async Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId)
259 {
260 var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId);
261 if (payment == null) return (false, "notfound");
262
263 var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
264 if (plan == null) return (false, "notfound");
265
266 if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId))
267 return (false, "forbidden");
268
269 if (payment.ToUserId != userId) return (false, "forbidden");
270
271 await ConfirmPaymentAsync(paymentId, userId);
272 return (true, null);
273 }
274
275 public async Task ConfirmPaymentAsync(Guid paymentId, Guid userId)
276 {
277 // DAL uses NoTrackingWithIdentityResolution, so every load returns a
278 // detached entity. Mutations only persist via an explicit Update() call.
279 var paymentRepo = _uow.GetRepository<SettlementPayment>();
280 var payment = await paymentRepo.GetByIdAsync(paymentId);
281 if (payment == null) return;
282 if (payment.ToUserId != userId) return;
283
284 payment.Status = EPaymentStatus.Confirmed;
285 payment.ConfirmedAt = DateTime.UtcNow;
286 paymentRepo.Update(payment);
287
288 // Read the plan with its Payments to check whether the plan is now
289 // fully confirmed. This load is read-only — used only for the All()
290 // check below — so we don't Update() it (its FromUser/ToUser includes
291 // would make DbSet.Update cascade into the AppUser graph and corrupt
292 // Identity rows on SaveChanges).
293 var planForCheck = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
294 if (planForCheck?.Payments == null)
295 {
296 await _uow.SaveChangesAsync();
297 return;
298 }
299
300 // The just-mutated payment is a different instance from the one inside
301 // planForCheck.Payments (no tracking → no identity map across queries).
302 // Treat the current paymentId as already Confirmed when checking.
303 var allConfirmed = planForCheck.Payments.All(p =>
304 p.Id == paymentId || p.Status == EPaymentStatus.Confirmed);
305
306 // Update plan + trip via the base repo (no Includes) so Update() only
307 // touches the plan/trip rows themselves.
308 var planRepo = _uow.GetRepository<SettlementPlan>();
309 var plan = await planRepo.GetByIdAsync(payment.SettlementPlanId);
310 if (plan == null)
311 {
312 await _uow.SaveChangesAsync();
313 return;
314 }
315
316 if (allConfirmed)
317 {
318 plan.Status = ESettlementStatus.Completed;
319 plan.CompletedAt = DateTime.UtcNow;
320
321 var tripRepo = _uow.GetRepository<Trip>();
322 var trip = await tripRepo.GetByIdAsync(plan.TripId);
323 if (trip != null && trip.Status == ETripStatus.Finalizing)
324 {
325 trip.Status = ETripStatus.Settled;
326 tripRepo.Update(trip);
327 }
328 }
329 else
330 {
331 plan.Status = ESettlementStatus.InProgress;
332 }
333 planRepo.Update(plan);
334
335 await _uow.SaveChangesAsync();
336 }
337 }
338