profileShare

rasmusjy / splitapp-backend-clean-onion

Read-only snapshot

No repository description.

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