profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

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